Revert "Revert "shield your eyes; massive, squashed refactor; nextjs/react/react-dom/apollo upgrades""
This reverts commit 18910fa2ed
.
This commit is contained in:
parent
441ec62476
commit
59f7b6ff26
|
@ -1,11 +1,11 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { inviteSchema, ssValidate } from '../../lib/validate'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
invites: async (parent, args, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
return await models.invite.findMany({
|
||||
|
@ -29,7 +29,7 @@ export default {
|
|||
Mutation: {
|
||||
createInvite: async (parent, { gift, limit }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
await ssValidate(inviteSchema, { gift, limit })
|
||||
|
@ -40,7 +40,7 @@ export default {
|
|||
},
|
||||
revokeInvite: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
return await models.invite.update({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { ensureProtocol, removeTracking } from '../../lib/url'
|
||||
import serialize from './serial'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
|
@ -6,7 +6,8 @@ import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
|||
import domino from 'domino'
|
||||
import {
|
||||
BOOST_MIN, ITEM_SPAM_INTERVAL,
|
||||
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT
|
||||
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
|
||||
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY
|
||||
} from '../../lib/constants'
|
||||
import { msatsToSats } from '../../lib/format'
|
||||
import { parse } from 'tldts'
|
||||
|
@ -14,6 +15,26 @@ import uu from 'url-unshort'
|
|||
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
||||
import { sendUserNotification } from '../webPush'
|
||||
import { proxyImages } from './imgproxy'
|
||||
import { defaultCommentSort } from '../../lib/item'
|
||||
|
||||
export async function commentFilterClause (me, models) {
|
||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||
if (me) {
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
// wild west mode has everything
|
||||
if (user.wildWestMode) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// always include if it's mine
|
||||
clause += ` OR "Item"."userId" = ${me.id}`
|
||||
}
|
||||
|
||||
// close the clause
|
||||
clause += ')'
|
||||
|
||||
return clause
|
||||
}
|
||||
|
||||
async function comments (me, models, id, sort) {
|
||||
let orderBy
|
||||
|
@ -53,9 +74,9 @@ export async function getItem (parent, { id }, { me, models }) {
|
|||
return item
|
||||
}
|
||||
|
||||
function topClause (within) {
|
||||
let interval = ' AND "Item".created_at >= $1 - INTERVAL '
|
||||
switch (within) {
|
||||
function whenClause (when, type) {
|
||||
let interval = ` AND "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL `
|
||||
switch (when) {
|
||||
case 'forever':
|
||||
interval = ''
|
||||
break
|
||||
|
@ -75,14 +96,16 @@ function topClause (within) {
|
|||
return interval
|
||||
}
|
||||
|
||||
async function topOrderClause (sort, me, models) {
|
||||
switch (sort) {
|
||||
const orderByClause = async (by, me, models, type) => {
|
||||
switch (by) {
|
||||
case 'comments':
|
||||
return 'ORDER BY ncomments DESC'
|
||||
return 'ORDER BY "Item".ncomments DESC'
|
||||
case 'sats':
|
||||
return 'ORDER BY msats DESC'
|
||||
default:
|
||||
return 'ORDER BY "Item".msats DESC'
|
||||
case 'votes':
|
||||
return await topOrderByWeightedSats(me, models)
|
||||
default:
|
||||
return `ORDER BY "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at DESC`
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,26 +135,17 @@ export async function joinSatRankView (me, models) {
|
|||
return 'JOIN sat_rank_tender_view ON "Item".id = sat_rank_tender_view.id'
|
||||
}
|
||||
|
||||
export async function commentFilterClause (me, models) {
|
||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||
if (me) {
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
// wild west mode has everything
|
||||
if (user.wildWestMode) {
|
||||
return ''
|
||||
export async function filterClause (me, models, type) {
|
||||
// if you are explicitly asking for marginal content, don't filter them
|
||||
if (['outlawed', 'borderland', 'freebies'].includes(type)) {
|
||||
if (me && ['outlawed', 'borderland'].includes(type)) {
|
||||
// unless the item is mine
|
||||
return ` AND "Item"."userId" <> ${me.id} `
|
||||
}
|
||||
|
||||
// always include if it's mine
|
||||
clause += ` OR "Item"."userId" = ${me.id}`
|
||||
return ''
|
||||
}
|
||||
|
||||
// close the clause
|
||||
clause += ')'
|
||||
|
||||
return clause
|
||||
}
|
||||
|
||||
export async function filterClause (me, models) {
|
||||
// by default don't include freebies unless they have upvotes
|
||||
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
|
||||
if (me) {
|
||||
|
@ -162,20 +176,33 @@ export async function filterClause (me, models) {
|
|||
return clause
|
||||
}
|
||||
|
||||
function recentClause (type) {
|
||||
function typeClause (type) {
|
||||
switch (type) {
|
||||
case 'links':
|
||||
return ' AND url IS NOT NULL'
|
||||
return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||
case 'discussions':
|
||||
return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL'
|
||||
return ' AND "Item".url IS NULL AND "Item".bio = false AND "Item"."pollCost" IS NULL AND "Item"."parentId" IS NULL'
|
||||
case 'polls':
|
||||
return ' AND "pollCost" IS NOT NULL'
|
||||
return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||
case 'bios':
|
||||
return ' AND bio = true'
|
||||
return ' AND "Item".bio = true AND "Item"."parentId" IS NULL'
|
||||
case 'bounties':
|
||||
return ' AND bounty IS NOT NULL'
|
||||
default:
|
||||
return ' AND "Item".bounty IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||
case 'comments':
|
||||
return ' AND "Item"."parentId" IS NOT NULL'
|
||||
case 'freebies':
|
||||
return ' AND "Item".freebie'
|
||||
case 'outlawed':
|
||||
return ` AND "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}`
|
||||
case 'borderland':
|
||||
return ' AND "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 '
|
||||
case 'all':
|
||||
case 'bookmarks':
|
||||
return ''
|
||||
case 'jobs':
|
||||
return ' AND "Item"."subName" = \'jobs\''
|
||||
default:
|
||||
return ' AND "Item"."parentId" IS NULL'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,6 +245,28 @@ const subClause = (sub, num, table, solo) => {
|
|||
return sub ? ` ${solo ? 'WHERE' : 'AND'} ${table ? `"${table}".` : ''}"subName" = $${num} ` : ''
|
||||
}
|
||||
|
||||
const relationClause = (type) => {
|
||||
switch (type) {
|
||||
case 'comments':
|
||||
return ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id '
|
||||
case 'bookmarks':
|
||||
return ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" '
|
||||
case 'outlawed':
|
||||
case 'borderland':
|
||||
case 'freebies':
|
||||
case 'all':
|
||||
return ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id '
|
||||
default:
|
||||
return ' FROM "Item" '
|
||||
}
|
||||
}
|
||||
|
||||
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
|
||||
|
||||
const activeOrMine = (me) => {
|
||||
return me ? ` AND ("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id}) ` : ' AND "Item".status <> \'STOPPED\' '
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
||||
|
@ -228,62 +277,9 @@ export default {
|
|||
|
||||
return count
|
||||
},
|
||||
topItems: async (parent, { sub, cursor, sort, when }, { me, models }) => {
|
||||
items: async (parent, { sub, sort, type, cursor, name, when, by, limit = LIMIT }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const subArr = sub ? [sub] : []
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND "Item".created_at <= $1
|
||||
AND "pinId" IS NULL AND "deletedAt" IS NULL
|
||||
${subClause(sub, 3)}
|
||||
${topClause(when)}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderClause(sort, me, models)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: await topOrderClause(sort, me, models)
|
||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
topComments: async (parent, { sub, cursor, sort, when }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const subArr = sub ? [sub] : []
|
||||
const comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
JOIN "Item" root ON "Item"."rootId" = root.id
|
||||
WHERE "Item"."parentId" IS NOT NULL
|
||||
AND "Item".created_at <= $1 AND "Item"."deletedAt" IS NULL
|
||||
${subClause(sub, 3, 'root')}
|
||||
${topClause(when)}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderClause(sort, me, models)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: await topOrderClause(sort, me, models)
|
||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
||||
return {
|
||||
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
comments
|
||||
}
|
||||
},
|
||||
items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let items; let user; let pins; let subFull
|
||||
|
||||
const activeOrMine = () => {
|
||||
return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' '
|
||||
}
|
||||
let items, user, pins, subFull, table
|
||||
|
||||
// HACK we want to optionally include the subName in the query
|
||||
// but the query planner doesn't like unused parameters
|
||||
|
@ -292,29 +288,32 @@ export default {
|
|||
switch (sort) {
|
||||
case 'user':
|
||||
if (!name) {
|
||||
throw new UserInputError('must supply name', { argumentName: 'name' })
|
||||
throw new GraphQLError('must supply name', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
user = await models.user.findUnique({ where: { name } })
|
||||
if (!user) {
|
||||
throw new UserInputError('no user has that name', { argumentName: 'name' })
|
||||
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
table = type === 'bookmarks' ? 'Bookmark' : 'Item'
|
||||
items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
|
||||
AND "pinId" IS NULL
|
||||
${activeOrMine()}
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
${relationClause(type)}
|
||||
WHERE "${table}"."userId" = $2 AND "${table}".created_at <= $1
|
||||
${subClause(sub, 5, subClauseTable(type))}
|
||||
${activeOrMine(me)}
|
||||
${await filterClause(me, models, type)}
|
||||
${typeClause(type)}
|
||||
${whenClause(when || 'forever', type)}
|
||||
${await orderByClause(by, me, models, type)}
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
LIMIT $4`,
|
||||
orderBy: await orderByClause(by, me, models, type)
|
||||
}, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'recent':
|
||||
items = await itemQueryWithMeta({
|
||||
|
@ -322,17 +321,17 @@ export default {
|
|||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND created_at <= $1
|
||||
${subClause(sub, 3)}
|
||||
${activeOrMine()}
|
||||
${await filterClause(me, models)}
|
||||
${recentClause(type)}
|
||||
ORDER BY created_at DESC
|
||||
${relationClause(type)}
|
||||
WHERE "Item".created_at <= $1
|
||||
${subClause(sub, 4, subClauseTable(type))}
|
||||
${activeOrMine(me)}
|
||||
${await filterClause(me, models, type)}
|
||||
${typeClause(type)}
|
||||
ORDER BY "Item".created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
case 'top':
|
||||
items = await itemQueryWithMeta({
|
||||
|
@ -340,16 +339,18 @@ export default {
|
|||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND "Item".created_at <= $1
|
||||
AND "pinId" IS NULL AND "deletedAt" IS NULL
|
||||
${topClause(within)}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderByWeightedSats(me, models)}
|
||||
${relationClause(type)}
|
||||
WHERE "Item".created_at <= $1
|
||||
AND "Item"."pinId" IS NULL AND "Item"."deletedAt" IS NULL
|
||||
${subClause(sub, 4, subClauseTable(type))}
|
||||
${typeClause(type)}
|
||||
${whenClause(when, type)}
|
||||
${await filterClause(me, models, type)}
|
||||
${await orderByClause(by || 'votes', me, models, type)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: await topOrderByWeightedSats(me, models)
|
||||
}, decodedCursor.time, decodedCursor.offset)
|
||||
LIMIT $3`,
|
||||
orderBy: await orderByClause(by || 'votes', me, models, type)
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
default:
|
||||
// sub so we know the default ranking
|
||||
|
@ -372,13 +373,13 @@ export default {
|
|||
FROM "Item"
|
||||
WHERE "parentId" IS NULL AND created_at <= $1
|
||||
AND "pinId" IS NULL
|
||||
${subClause(sub, 3)}
|
||||
${subClause(sub, 4)}
|
||||
AND status IN ('ACTIVE', 'NOSATS')
|
||||
ORDER BY group_rank, rank
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY group_rank, rank'
|
||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
||||
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||
break
|
||||
default:
|
||||
items = await itemQueryWithMeta({
|
||||
|
@ -388,12 +389,12 @@ export default {
|
|||
${SELECT}, rank
|
||||
FROM "Item"
|
||||
${await joinSatRankView(me, models)}
|
||||
${subClause(sub, 2, 'Item', true)}
|
||||
${subClause(sub, 3, 'Item', true)}
|
||||
ORDER BY rank ASC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`,
|
||||
LIMIT $2`,
|
||||
orderBy: 'ORDER BY rank ASC'
|
||||
}, decodedCursor.offset, ...subArr)
|
||||
}, decodedCursor.offset, limit, ...subArr)
|
||||
|
||||
if (decodedCursor.offset === 0) {
|
||||
// get pins for the page and return those separately
|
||||
|
@ -419,230 +420,11 @@ export default {
|
|||
break
|
||||
}
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
|
||||
items,
|
||||
pins
|
||||
}
|
||||
},
|
||||
allItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
outlawedItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const notMine = () => {
|
||||
return me ? ` AND "userId" <> ${me.id} ` : ''
|
||||
}
|
||||
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
|
||||
${notMine()}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
borderlandItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const notMine = () => {
|
||||
return me ? ` AND "userId" <> ${me.id} ` : ''
|
||||
}
|
||||
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
|
||||
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
|
||||
${notMine()}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
freebieItems: async (parent, { cursor }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item".freebie
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $1
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.offset)
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
getBountiesByUserName: async (parent, { name, cursor, limit }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
const user = await models.user.findUnique({ where: { name } })
|
||||
|
||||
if (!user) {
|
||||
throw new UserInputError('user not found', {
|
||||
argumentName: 'name'
|
||||
})
|
||||
}
|
||||
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "userId" = $1
|
||||
AND "bounty" IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT $3`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, user.id, decodedCursor.offset, limit || LIMIT)
|
||||
|
||||
return {
|
||||
cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
moreFlatComments: async (parent, { sub, cursor, name, sort, within }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
// HACK we want to optionally include the subName in the query
|
||||
// but the query planner doesn't like unused parameters
|
||||
const subArr = sub ? [sub] : []
|
||||
|
||||
let comments, user
|
||||
switch (sort) {
|
||||
case 'recent':
|
||||
comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
JOIN "Item" root ON "Item"."rootId" = root.id
|
||||
WHERE "Item"."parentId" IS NOT NULL AND "Item".created_at <= $1
|
||||
${subClause(sub, 3, 'root')}
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY "Item".created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
||||
break
|
||||
case 'user':
|
||||
if (!name) {
|
||||
throw new UserInputError('must supply name', { argumentName: 'name' })
|
||||
}
|
||||
|
||||
user = await models.user.findUnique({ where: { name } })
|
||||
if (!user) {
|
||||
throw new UserInputError('no user has that name', { argumentName: 'name' })
|
||||
}
|
||||
|
||||
comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "userId" = $1 AND "parentId" IS NOT NULL
|
||||
AND created_at <= $2
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||
}, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
break
|
||||
case 'top':
|
||||
comments = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}
|
||||
FROM "Item"
|
||||
WHERE "Item"."parentId" IS NOT NULL AND"Item"."deletedAt" IS NULL
|
||||
AND "Item".created_at <= $1
|
||||
${topClause(within)}
|
||||
${await filterClause(me, models)}
|
||||
${await topOrderByWeightedSats(me, models)}
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: await topOrderByWeightedSats(me, models)
|
||||
}, decodedCursor.time, decodedCursor.offset)
|
||||
break
|
||||
default:
|
||||
throw new UserInputError('invalid sort type', { argumentName: 'sort' })
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
comments
|
||||
}
|
||||
},
|
||||
moreBookmarks: async (parent, { cursor, name }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
|
||||
const user = await models.user.findUnique({ where: { name } })
|
||||
if (!user) {
|
||||
throw new UserInputError('no user has that name', { argumentName: 'name' })
|
||||
}
|
||||
|
||||
const items = await itemQueryWithMeta({
|
||||
me,
|
||||
models,
|
||||
query: `
|
||||
${SELECT}, "Bookmark".created_at as "bookmarkCreatedAt"
|
||||
FROM "Item"
|
||||
JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" AND "Bookmark"."userId" = $1
|
||||
AND "Bookmark".created_at <= $2
|
||||
ORDER BY "Bookmark".created_at DESC
|
||||
OFFSET $3
|
||||
LIMIT ${LIMIT}`,
|
||||
orderBy: 'ORDER BY "bookmarkCreatedAt" DESC'
|
||||
}, user.id, decodedCursor.time, decodedCursor.offset)
|
||||
|
||||
return {
|
||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
||||
items
|
||||
}
|
||||
},
|
||||
item: getItem,
|
||||
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
||||
const res = {}
|
||||
|
@ -758,7 +540,7 @@ export default {
|
|||
deleteItem: async (parent, { id }, { me, models }) => {
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
const data = { deletedAt: new Date() }
|
||||
|
@ -813,9 +595,9 @@ export default {
|
|||
}
|
||||
},
|
||||
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
|
||||
const { sub, forward, boost, title, text, options } = data
|
||||
const { forward, sub, boost, title, text, options } = data
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
const optionCount = id
|
||||
|
@ -832,14 +614,14 @@ export default {
|
|||
if (forward) {
|
||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||
if (!fwdUser) {
|
||||
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
|
||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
const [item] = await serialize(models,
|
||||
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
|
||||
|
@ -860,13 +642,13 @@ export default {
|
|||
},
|
||||
upsertJob: async (parent, { id, ...data }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in to create job')
|
||||
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
const { sub, title, company, location, remote, text, url, maxBid, status, logo } = data
|
||||
|
||||
const fullSub = await models.sub.findUnique({ where: { name: sub } })
|
||||
if (!fullSub) {
|
||||
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
|
||||
throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
await ssValidate(jobSchema, data, models)
|
||||
|
@ -876,7 +658,7 @@ export default {
|
|||
if (id) {
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
([item] = await serialize(models,
|
||||
models.$queryRaw(
|
||||
|
@ -919,7 +701,7 @@ export default {
|
|||
},
|
||||
pollVote: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
await serialize(models,
|
||||
|
@ -931,7 +713,7 @@ export default {
|
|||
act: async (parent, { id, sats }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
await ssValidate(amountSchema, { amount: sats })
|
||||
|
@ -942,7 +724,7 @@ export default {
|
|||
FROM "Item"
|
||||
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
||||
if (item) {
|
||||
throw new UserInputError('cannot zap your self')
|
||||
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
|
||||
|
@ -964,7 +746,7 @@ export default {
|
|||
dontLikeThis: async (parent, { id }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
// disallow self down votes
|
||||
|
@ -973,7 +755,7 @@ export default {
|
|||
FROM "Item"
|
||||
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
||||
if (item) {
|
||||
throw new UserInputError('cannot downvote your self')
|
||||
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
|
||||
|
@ -992,11 +774,11 @@ export default {
|
|||
return item.subName === 'jobs'
|
||||
},
|
||||
sub: async (item, args, { models }) => {
|
||||
if (!item.subName) {
|
||||
if (!item.subName && !item.root) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await models.sub.findUnique({ where: { name: item.subName } })
|
||||
return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
|
||||
},
|
||||
position: async (item, args, { models }) => {
|
||||
if (!item.pinId) {
|
||||
|
@ -1070,7 +852,8 @@ export default {
|
|||
if (item.comments) {
|
||||
return item.comments
|
||||
}
|
||||
return comments(me, models, item.id, item.pinId ? 'recent' : 'hot')
|
||||
|
||||
return comments(me, models, item.id, defaultCommentSort(item.pinId, item.bioId, item.createdAt))
|
||||
},
|
||||
wvotes: async (item) => {
|
||||
return item.weightedVotes - item.weightedDownVotes
|
||||
|
@ -1226,28 +1009,28 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
|
|||
// update iff this item belongs to me
|
||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||
if (Number(old.userId) !== Number(me?.id)) {
|
||||
throw new AuthenticationError('item does not belong to you')
|
||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
// if it's not the FAQ, not their bio, and older than 10 minutes
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
|
||||
throw new UserInputError('item can no longer be editted')
|
||||
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (boost && boost < BOOST_MIN) {
|
||||
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
|
||||
throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (!old.parentId && title.length > MAX_TITLE_LENGTH) {
|
||||
throw new UserInputError('title too long')
|
||||
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
let fwdUser
|
||||
if (forward) {
|
||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||
if (!fwdUser) {
|
||||
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
|
||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1267,22 +1050,22 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
|
|||
|
||||
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
if (boost && boost < BOOST_MIN) {
|
||||
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
|
||||
throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (!parentId && title.length > MAX_TITLE_LENGTH) {
|
||||
throw new UserInputError('title too long')
|
||||
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
let fwdUser
|
||||
if (forward) {
|
||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||
if (!fwdUser) {
|
||||
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
|
||||
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { randomBytes } from 'crypto'
|
||||
import { bech32 } from 'bech32'
|
||||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
|
||||
function encodedUrl (iurl, tag, k1) {
|
||||
const url = new URL(iurl)
|
||||
|
@ -30,7 +30,7 @@ export default {
|
|||
},
|
||||
createWith: async (parent, args, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
return await models.lnWith.create({ data: { k1: k1(), userId: me.id } })
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserInputError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
|
@ -11,7 +11,7 @@ export default {
|
|||
Mutation: {
|
||||
createMessage: async (parent, { text }, { me, models }) => {
|
||||
if (!text) {
|
||||
throw new UserInputError('Must have text', { argumentName: 'text' })
|
||||
throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
return await models.message.create({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { getItem, filterClause } from './item'
|
||||
import { getInvoice } from './wallet'
|
||||
|
@ -10,7 +10,7 @@ export default {
|
|||
notifications: async (parent, { cursor, inc }, { me, models }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
const meFull = await models.user.findUnique({ where: { id: me.id } })
|
||||
|
@ -228,7 +228,7 @@ export default {
|
|||
Mutation: {
|
||||
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
|
||||
|
@ -250,14 +250,12 @@ export default {
|
|||
},
|
||||
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
|
||||
if (!subscription) {
|
||||
throw new UserInputError('endpoint not found', {
|
||||
argumentName: 'endpoint'
|
||||
})
|
||||
throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
await models.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
return subscription
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { withClause, intervalClause, timeUnit } from './growth'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
referrals: async (parent, { when }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
const [{ totalSats }] = await models.$queryRaw(`
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { amountSchema, ssValidate } from '../../lib/validate'
|
||||
import serialize from './serial'
|
||||
|
||||
|
@ -36,7 +36,7 @@ export default {
|
|||
Mutation: {
|
||||
donateToRewards: async (parent, { sats }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(amountSchema, { amount: sats })
|
||||
|
|
|
@ -79,7 +79,7 @@ export default {
|
|||
items
|
||||
}
|
||||
},
|
||||
search: async (parent, { q: query, cursor, sort, what, when }, { me, models, search }) => {
|
||||
search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let sitems
|
||||
|
||||
|
@ -105,8 +105,7 @@ export default {
|
|||
const queryArr = query.trim().split(/\s+/)
|
||||
const url = queryArr.find(word => word.startsWith('url:'))
|
||||
const nym = queryArr.find(word => word.startsWith('nym:'))
|
||||
const sub = queryArr.find(word => word.startsWith('~'))
|
||||
const exclude = [url, nym, sub]
|
||||
const exclude = [url, nym]
|
||||
query = queryArr.filter(word => !exclude.includes(word)).join(' ')
|
||||
|
||||
if (url) {
|
||||
|
@ -118,7 +117,7 @@ export default {
|
|||
}
|
||||
|
||||
if (sub) {
|
||||
whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } })
|
||||
whatArr.push({ match: { 'sub.name': sub } })
|
||||
}
|
||||
|
||||
const sortArr = []
|
||||
|
@ -247,7 +246,7 @@ export default {
|
|||
highlight: {
|
||||
fields: {
|
||||
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
|
||||
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
|
||||
text: { number_of_fragments: 5, order: 'score', pre_tags: [':high['], post_tags: [']'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -266,7 +265,7 @@ export default {
|
|||
const item = await getItem(parent, { id: e._source.id }, { me, models })
|
||||
|
||||
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
||||
item.searchText = (e.highlight?.text && e.highlight.text[0]) || item.text
|
||||
item.searchText = (e.highlight?.text && e.highlight.text.join(' `...` ')) || undefined
|
||||
|
||||
return item
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const { UserInputError } = require('apollo-server-micro')
|
||||
const { GraphQLError } = require('graphql')
|
||||
const retry = require('async-retry')
|
||||
|
||||
async function serialize (models, call) {
|
||||
|
@ -12,7 +12,7 @@ async function serialize (models, call) {
|
|||
} catch (error) {
|
||||
console.log(error)
|
||||
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
|
||||
bail(new UserInputError('insufficient funds'))
|
||||
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
|
||||
}
|
||||
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
|
||||
bail(new Error('wallet balance transaction is not serializable'))
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
export default {
|
||||
Query: {
|
||||
sub: async (parent, { name }, { models, me }) => {
|
||||
if (!name) return null
|
||||
|
||||
if (me && name === 'jobs') {
|
||||
models.user.update({
|
||||
where: {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import AWS from 'aws-sdk'
|
||||
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
||||
|
||||
|
@ -12,19 +12,19 @@ export default {
|
|||
Mutation: {
|
||||
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in to get a signed url')
|
||||
throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
||||
throw new UserInputError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
||||
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (size > UPLOAD_SIZE_MAX) {
|
||||
throw new UserInputError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`)
|
||||
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (width * height > IMAGE_PIXELS_MAX) {
|
||||
throw new UserInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`)
|
||||
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
// create upload record
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { AuthenticationError, UserInputError } from 'apollo-server-errors'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import { msatsToSats } from '../../lib/format'
|
||||
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
|
||||
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
||||
import serialize from './serial'
|
||||
import { dayPivot } from '../../lib/time'
|
||||
|
||||
export function within (table, within) {
|
||||
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
|
||||
|
@ -53,13 +54,13 @@ export function viewWithin (table, within) {
|
|||
export function withinDate (within) {
|
||||
switch (within) {
|
||||
case 'day':
|
||||
return new Date(new Date().setDate(new Date().getDate() - 1))
|
||||
return dayPivot(new Date(), -1)
|
||||
case 'week':
|
||||
return new Date(new Date().setDate(new Date().getDate() - 7))
|
||||
return dayPivot(new Date(), -7)
|
||||
case 'month':
|
||||
return new Date(new Date().setDate(new Date().getDate() - 30))
|
||||
return dayPivot(new Date(), -30)
|
||||
case 'year':
|
||||
return new Date(new Date().setDate(new Date().getDate() - 365))
|
||||
return dayPivot(new Date(), -365)
|
||||
default:
|
||||
return new Date(0)
|
||||
}
|
||||
|
@ -97,7 +98,7 @@ export default {
|
|||
},
|
||||
settings: async (parent, args, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
return await models.user.findUnique({ where: { id: me.id } })
|
||||
|
@ -109,7 +110,7 @@ export default {
|
|||
await models.user.findMany(),
|
||||
nameAvailable: async (parent, { name }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||
|
@ -120,7 +121,7 @@ export default {
|
|||
const decodedCursor = decodeCursor(cursor)
|
||||
const users = await models.$queryRaw(`
|
||||
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
|
||||
sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals,
|
||||
sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals,
|
||||
floor(sum(msats_stacked)/1000) as stacked
|
||||
FROM users
|
||||
LEFT JOIN user_stats_days on users.id = user_stats_days.id
|
||||
|
@ -134,15 +135,15 @@ export default {
|
|||
users
|
||||
}
|
||||
},
|
||||
topUsers: async (parent, { cursor, when, sort }, { models, me }) => {
|
||||
topUsers: async (parent, { cursor, when, by }, { models, me }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
let users
|
||||
|
||||
if (when !== 'day') {
|
||||
let column
|
||||
switch (sort) {
|
||||
switch (by) {
|
||||
case 'spent': column = 'spent'; break
|
||||
case 'posts': column = 'nitems'; break
|
||||
case 'posts': column = 'nposts'; break
|
||||
case 'comments': column = 'ncomments'; break
|
||||
case 'referrals': column = 'referrals'; break
|
||||
default: column = 'stacked'; break
|
||||
|
@ -151,7 +152,7 @@ export default {
|
|||
users = await models.$queryRaw(`
|
||||
WITH u AS (
|
||||
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
|
||||
sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals,
|
||||
sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals,
|
||||
floor(sum(msats_stacked)/1000) as stacked
|
||||
FROM user_stats_days
|
||||
JOIN users on users.id = user_stats_days.id
|
||||
|
@ -170,7 +171,7 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
if (sort === 'spent') {
|
||||
if (by === 'spent') {
|
||||
users = await models.$queryRaw(`
|
||||
SELECT users.*, sum(sats_spent) as spent
|
||||
FROM
|
||||
|
@ -190,19 +191,19 @@ export default {
|
|||
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
} else if (sort === 'posts') {
|
||||
} else if (by === 'posts') {
|
||||
users = await models.$queryRaw(`
|
||||
SELECT users.*, count(*) as nitems
|
||||
SELECT users.*, count(*) as nposts
|
||||
FROM users
|
||||
JOIN "Item" on "Item"."userId" = users.id
|
||||
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL
|
||||
AND NOT users."hideFromTopUsers"
|
||||
${within('Item', when)}
|
||||
GROUP BY users.id
|
||||
ORDER BY nitems DESC NULLS LAST, users.created_at DESC
|
||||
ORDER BY nposts DESC NULLS LAST, users.created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
} else if (sort === 'comments') {
|
||||
} else if (by === 'comments') {
|
||||
users = await models.$queryRaw(`
|
||||
SELECT users.*, count(*) as ncomments
|
||||
FROM users
|
||||
|
@ -214,7 +215,7 @@ export default {
|
|||
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
|
||||
OFFSET $2
|
||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||
} else if (sort === 'referrals') {
|
||||
} else if (by === 'referrals') {
|
||||
users = await models.$queryRaw(`
|
||||
SELECT users.*, count(*) as referrals
|
||||
FROM users
|
||||
|
@ -427,23 +428,24 @@ export default {
|
|||
Mutation: {
|
||||
setName: async (parent, data, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(userSchema, data, models)
|
||||
|
||||
try {
|
||||
await models.user.update({ where: { id: me.id }, data })
|
||||
return data.name
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new UserInputError('name taken')
|
||||
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(settingsSchema, { nostrRelays, ...data })
|
||||
|
@ -469,7 +471,7 @@ export default {
|
|||
},
|
||||
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
|
||||
|
@ -478,7 +480,7 @@ export default {
|
|||
},
|
||||
setPhoto: async (parent, { photoId }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await models.user.update({
|
||||
|
@ -490,7 +492,7 @@ export default {
|
|||
},
|
||||
upsertBio: async (parent, { bio }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(bioSchema, { bio })
|
||||
|
@ -510,7 +512,7 @@ export default {
|
|||
},
|
||||
unlinkAuth: async (parent, { authType }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
let user
|
||||
|
@ -518,7 +520,7 @@ export default {
|
|||
user = await models.user.findUnique({ where: { id: me.id } })
|
||||
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
|
||||
if (!account) {
|
||||
throw new UserInputError('no such account')
|
||||
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
await models.account.delete({ where: { id: account.id } })
|
||||
} else if (authType === 'lightning') {
|
||||
|
@ -528,14 +530,14 @@ export default {
|
|||
} else if (authType === 'email') {
|
||||
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
|
||||
} else {
|
||||
throw new UserInputError('no such account')
|
||||
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
return await authMethods(user, undefined, { models, me })
|
||||
},
|
||||
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
|
||||
await ssValidate(emailSchema, { email })
|
||||
|
@ -547,7 +549,7 @@ export default {
|
|||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new UserInputError('email taken')
|
||||
throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
@ -581,6 +583,20 @@ export default {
|
|||
return user.nitems
|
||||
}
|
||||
|
||||
return await models.item.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt: {
|
||||
gte: withinDate(when)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
nposts: async (user, { when }, { models }) => {
|
||||
if (typeof user.nposts === 'number') {
|
||||
return user.nposts
|
||||
}
|
||||
|
||||
return await models.item.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
||||
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import serialize from './serial'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||
import lnpr from 'bolt11'
|
||||
|
@ -10,7 +10,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l
|
|||
|
||||
export async function getInvoice (parent, { id }, { me, models }) {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
const inv = await models.invoice.findUnique({
|
||||
|
@ -23,7 +23,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
|
|||
})
|
||||
|
||||
if (inv.user.id !== me.id) {
|
||||
throw new AuthenticationError('not ur invoice')
|
||||
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
return inv
|
||||
|
@ -34,7 +34,7 @@ export default {
|
|||
invoice: getInvoice,
|
||||
withdrawl: async (parent, { id }, { me, models, lnd }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
const wdrwl = await models.withdrawl.findUnique({
|
||||
|
@ -47,7 +47,7 @@ export default {
|
|||
})
|
||||
|
||||
if (wdrwl.user.id !== me.id) {
|
||||
throw new AuthenticationError('not ur withdrawal')
|
||||
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
return wdrwl
|
||||
|
@ -58,7 +58,7 @@ export default {
|
|||
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
|
||||
const decodedCursor = decodeCursor(cursor)
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
const include = new Set(inc?.split(','))
|
||||
|
@ -191,7 +191,7 @@ export default {
|
|||
Mutation: {
|
||||
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
await ssValidate(amountSchema, { amount })
|
||||
|
@ -239,9 +239,7 @@ export default {
|
|||
const milliamount = amount * 1000
|
||||
// check that amount is within min and max sendable
|
||||
if (milliamount < res1.minSendable || milliamount > res1.maxSendable) {
|
||||
throw new UserInputError(
|
||||
`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`,
|
||||
{ argumentName: 'amount' })
|
||||
throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
const callback = new URL(res1.callback)
|
||||
|
@ -311,11 +309,11 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd
|
|||
decoded = await decodePaymentRequest({ lnd, request: invoice })
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
throw new UserInputError('could not decode invoice')
|
||||
throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
|
||||
throw new UserInputError('your invoice must specify an amount')
|
||||
throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
const msatsFee = Number(maxFee) * 1000
|
||||
|
|
|
@ -31,16 +31,38 @@ export default async function getSSRApolloClient (req, me = null) {
|
|||
slashtags
|
||||
}
|
||||
}),
|
||||
cache: new InMemoryCache()
|
||||
cache: new InMemoryCache({
|
||||
freezeResults: true
|
||||
}),
|
||||
assumeImmutableResults: true,
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: 'cache-only',
|
||||
nextFetchPolicy: 'cache-only',
|
||||
canonizeResults: true,
|
||||
ssr: true
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: 'cache-first',
|
||||
nextFetchPolicy: 'cache-only',
|
||||
canonizeResults: true,
|
||||
ssr: true
|
||||
}
|
||||
}
|
||||
})
|
||||
await client.clearStore()
|
||||
return client
|
||||
}
|
||||
|
||||
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
|
||||
export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notFoundFunc, requireVar) {
|
||||
return async function ({ req, query: params }) {
|
||||
const { nodata, ...realParams } = params
|
||||
// we want to use client-side cache
|
||||
if (nodata) return { props: { } }
|
||||
|
||||
const variables = typeof variablesOrFunc === 'function' ? variablesOrFunc(realParams) : variablesOrFunc
|
||||
const vars = { ...realParams, ...variables }
|
||||
const query = typeof queryOrFunc === 'function' ? queryOrFunc(vars) : queryOrFunc
|
||||
|
||||
const client = await getSSRApolloClient(req)
|
||||
|
||||
const { data: { me } } = await client.query({
|
||||
|
@ -52,20 +74,6 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||
query: PRICE, variables: { fiatCurrency: me?.fiatCurrency }
|
||||
})
|
||||
|
||||
// we want to use client-side cache
|
||||
if (nodata && query) {
|
||||
return {
|
||||
props: {
|
||||
me,
|
||||
price,
|
||||
apollo: {
|
||||
query: print(query),
|
||||
variables: vars
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requireVar && !vars[requireVar]) {
|
||||
return {
|
||||
notFound: true
|
||||
|
@ -91,7 +99,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||
throw err
|
||||
}
|
||||
|
||||
if (error || !data || (notFoundFunc && notFoundFunc(data))) {
|
||||
if (error || !data || (notFoundFunc && notFoundFunc(data, vars))) {
|
||||
return {
|
||||
notFound: true
|
||||
}
|
||||
|
@ -110,7 +118,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||
...props,
|
||||
me,
|
||||
price,
|
||||
data
|
||||
ssrData: data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
type NameValue {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
import user from './user'
|
||||
import message from './message'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,25 +1,16 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
||||
moreFlatComments(sub: String, sort: String!, cursor: String, name: String, within: String): Comments
|
||||
moreBookmarks(cursor: String, name: String!): Items
|
||||
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): Items
|
||||
item(id: ID!): Item
|
||||
comments(id: ID!, sort: String): [Item!]!
|
||||
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
||||
dupes(url: String!): [Item!]
|
||||
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
|
||||
allItems(cursor: String): Items
|
||||
getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items
|
||||
search(q: String, cursor: String, what: String, sort: String, when: String): Items
|
||||
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
|
||||
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
||||
itemRepetition(parentId: ID): Int!
|
||||
outlawedItems(cursor: String): Items
|
||||
borderlandItems(cursor: String): Items
|
||||
freebieItems(cursor: String): Items
|
||||
topItems(cursor: String, sub: String, sort: String, when: String): Items
|
||||
topComments(cursor: String, sub: String, sort: String, when: String): Comments
|
||||
}
|
||||
|
||||
type TitleUnshorted {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
@ -11,33 +11,39 @@ export default gql`
|
|||
}
|
||||
|
||||
type Votification {
|
||||
id: ID!
|
||||
earnedSats: Int!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Reply {
|
||||
id: ID!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Mention {
|
||||
id: ID!
|
||||
mention: Boolean!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Invitification {
|
||||
id: ID!
|
||||
invite: Invite!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type JobChanged {
|
||||
id: ID!
|
||||
item: Item!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type EarnSources {
|
||||
id: ID!
|
||||
posts: Int!
|
||||
comments: Int!
|
||||
tipPosts: Int!
|
||||
|
@ -45,24 +51,27 @@ export default gql`
|
|||
}
|
||||
|
||||
type Streak {
|
||||
id: ID!
|
||||
sortTime: String!
|
||||
days: Int
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type Earn {
|
||||
id: ID!
|
||||
earnedSats: Int!
|
||||
sortTime: String!
|
||||
sources: EarnSources
|
||||
}
|
||||
|
||||
type InvoicePaid {
|
||||
id: ID!
|
||||
earnedSats: Int!
|
||||
invoice: Invoice!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
type Referral {
|
||||
id: ID!
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
sub(name: String!): Sub
|
||||
sub(name: String): Sub
|
||||
subLatestPost(name: String!): String
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
scalar JSONObject
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
@ -7,7 +7,7 @@ export default gql`
|
|||
user(name: String!): User
|
||||
users: [User!]
|
||||
nameAvailable(name: String!): Boolean!
|
||||
topUsers(cursor: String, when: String, sort: String): Users
|
||||
topUsers(cursor: String, when: String, by: String): Users
|
||||
topCowboys(cursor: String): Users
|
||||
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
||||
hasNewNotes: Boolean!
|
||||
|
@ -19,7 +19,7 @@ export default gql`
|
|||
}
|
||||
|
||||
extend type Mutation {
|
||||
setName(name: String!): Boolean
|
||||
setName(name: String!): String
|
||||
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
|
||||
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
|
||||
|
@ -45,6 +45,7 @@ export default gql`
|
|||
createdAt: String!
|
||||
name: String
|
||||
nitems(when: String): Int!
|
||||
nposts(when: String): Int!
|
||||
ncomments(when: String): Int!
|
||||
nbookmarks(when: String): Int!
|
||||
stacked(when: String): Int!
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
|
|
|
@ -1,74 +1,72 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import AvatarEditor from 'react-avatar-editor'
|
||||
import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap'
|
||||
import { Button, Form as BootstrapForm } from 'react-bootstrap'
|
||||
import Upload from './upload'
|
||||
import EditImage from '../svgs/image-edit-fill.svg'
|
||||
import Moon from '../svgs/moon-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export default function Avatar ({ onSuccess }) {
|
||||
const [uploading, setUploading] = useState()
|
||||
const [editProps, setEditProps] = useState()
|
||||
const ref = useRef()
|
||||
const [scale, setScale] = useState(1)
|
||||
const showModal = useShowModal()
|
||||
|
||||
const Body = ({ onClose, file, upload }) => {
|
||||
return (
|
||||
<div className='text-right mt-1 p-4'>
|
||||
<AvatarEditor
|
||||
ref={ref} width={200} height={200}
|
||||
image={file}
|
||||
scale={scale}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
/>
|
||||
<BootstrapForm.Group controlId='formBasicRange'>
|
||||
<BootstrapForm.Control
|
||||
type='range' onChange={e => setScale(parseFloat(e.target.value))}
|
||||
min={1} max={2} step='0.05'
|
||||
defaultValue={scale} custom
|
||||
/>
|
||||
</BootstrapForm.Group>
|
||||
<Button onClick={() => {
|
||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||
if (blob) {
|
||||
upload(blob)
|
||||
onClose()
|
||||
}
|
||||
}, 'image/jpeg')
|
||||
}}
|
||||
>save
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
show={!!editProps}
|
||||
onHide={() => setEditProps(null)}
|
||||
>
|
||||
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
|
||||
<Modal.Body className='text-right mt-1 p-4'>
|
||||
<AvatarEditor
|
||||
ref={ref} width={200} height={200}
|
||||
image={editProps?.file}
|
||||
scale={scale}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
/>
|
||||
<BootstrapForm.Group controlId='formBasicRange'>
|
||||
<BootstrapForm.Control
|
||||
type='range' onChange={e => setScale(parseFloat(e.target.value))}
|
||||
min={1} max={2} step='0.05'
|
||||
defaultValue={scale} custom
|
||||
/>
|
||||
</BootstrapForm.Group>
|
||||
<Button onClick={() => {
|
||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||
if (blob) {
|
||||
editProps.upload(blob)
|
||||
setEditProps(null)
|
||||
}
|
||||
}, 'image/jpeg')
|
||||
}}
|
||||
>save
|
||||
</Button>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Upload
|
||||
as={({ onClick }) =>
|
||||
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
||||
{uploading
|
||||
? <Moon className='fill-white spin' />
|
||||
: <EditImage className='fill-white' />}
|
||||
</div>}
|
||||
onError={e => {
|
||||
console.log(e)
|
||||
setUploading(false)
|
||||
}}
|
||||
onSelect={(file, upload) => {
|
||||
setEditProps({ file, upload })
|
||||
}}
|
||||
onSuccess={async key => {
|
||||
onSuccess && onSuccess(key)
|
||||
setUploading(false)
|
||||
}}
|
||||
onStarted={() => {
|
||||
setUploading(true)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Upload
|
||||
as={({ onClick }) =>
|
||||
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
||||
{uploading
|
||||
? <Moon className='fill-white spin' />
|
||||
: <EditImage className='fill-white' />}
|
||||
</div>}
|
||||
onError={e => {
|
||||
console.log(e)
|
||||
setUploading(false)
|
||||
}}
|
||||
onSelect={(file, upload) => {
|
||||
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
|
||||
}}
|
||||
onSuccess={async key => {
|
||||
onSuccess && onSuccess(key)
|
||||
setUploading(false)
|
||||
}}
|
||||
onStarted={() => {
|
||||
setUploading(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
|
||||
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
|
@ -100,7 +99,6 @@ export function BountyForm ({
|
|||
</>
|
||||
}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
hint={
|
||||
editThreshold
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Form, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import styles from './reply.module.css'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { EditFeeButton } from './fee-button'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import Delete from './delete'
|
||||
|
@ -47,7 +46,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||
>
|
||||
<MarkdownInput
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
autoFocus
|
||||
required
|
||||
|
|
|
@ -3,7 +3,7 @@ import styles from './comment.module.css'
|
|||
import Text from './text'
|
||||
import Link from 'next/link'
|
||||
import Reply, { ReplyOnAnotherPage } from './reply'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import UpVote from './upvote'
|
||||
import Eye from '../svgs/eye-fill.svg'
|
||||
import EyeClose from '../svgs/eye-close-line.svg'
|
||||
|
@ -28,8 +28,8 @@ function Parent ({ item, rootText }) {
|
|||
const ParentFrag = () => (
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.parentId}`} passHref>
|
||||
<a className='text-reset'>parent</a>
|
||||
<Link href={`/items/${item.parentId}`} className='text-reset'>
|
||||
parent
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
|
@ -38,12 +38,12 @@ function Parent ({ item, rootText }) {
|
|||
<>
|
||||
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${root.id}`} passHref>
|
||||
<a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
|
||||
<Link href={`/items/${root.id}`} className='text-reset'>
|
||||
{rootText || 'on:'} {root?.title}
|
||||
</Link>
|
||||
{root.subName &&
|
||||
<Link href={`/~${root.subName}`}>
|
||||
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge></a>
|
||||
{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge>
|
||||
</Link>}
|
||||
</>
|
||||
)
|
||||
|
@ -54,32 +54,42 @@ const truncateString = (string = '', maxLength = 140) =>
|
|||
? `${string.substring(0, maxLength)} […]`
|
||||
: string
|
||||
|
||||
export function CommentFlat ({ item, ...props }) {
|
||||
export function CommentFlat ({ item, rank, ...props }) {
|
||||
const router = useRouter()
|
||||
const [href, as] = useMemo(() => {
|
||||
if (item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) {
|
||||
return [{
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.parentId, commentId: item.id }
|
||||
}, `/items/${item.parentId}`]
|
||||
} else {
|
||||
return [{
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.root.id, commentId: item.id }
|
||||
}, `/items/${item.root.id}`]
|
||||
}
|
||||
}, [item?.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='clickToContext py-2'
|
||||
onClick={e => {
|
||||
if (ignoreClick(e)) {
|
||||
return
|
||||
}
|
||||
if (item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) {
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.parentId, commentId: item.id }
|
||||
}, `/items/${item.parentId}`)
|
||||
} else {
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: item.root.id, commentId: item.id }
|
||||
}, `/items/${item.root.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RootProvider root={item.root}>
|
||||
<Comment item={item} {...props} />
|
||||
</RootProvider>
|
||||
</div>
|
||||
<>
|
||||
{rank
|
||||
? (
|
||||
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
|
||||
{rank}
|
||||
</div>)
|
||||
: <div />}
|
||||
<div
|
||||
className='clickToContext py-2'
|
||||
onClick={e => {
|
||||
if (ignoreClick(e)) return
|
||||
router.push(href, as)
|
||||
}}
|
||||
>
|
||||
<RootProvider root={item.root}>
|
||||
<Comment item={item} {...props} />
|
||||
</RootProvider>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -183,24 +193,26 @@ export default function Comment ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bottomedOut
|
||||
? <DepthLimit item={item} />
|
||||
: (
|
||||
<div className={`${styles.children}`}>
|
||||
{!noReply &&
|
||||
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
|
||||
{root.bounty && !bountyPaid && <PayBounty item={item} />}
|
||||
</Reply>}
|
||||
{children}
|
||||
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>
|
||||
{item.comments && !noComments
|
||||
? item.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||
))
|
||||
: null}
|
||||
{collapse !== 'yep' && (
|
||||
bottomedOut
|
||||
? <DepthLimit item={item} />
|
||||
: (
|
||||
<div className={`${styles.children}`}>
|
||||
{!noReply &&
|
||||
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
|
||||
{root.bounty && !bountyPaid && <PayBounty item={item} />}
|
||||
</Reply>}
|
||||
{children}
|
||||
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>
|
||||
{item.comments && !noComments
|
||||
? item.comments.map((item) => (
|
||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -208,8 +220,8 @@ export default function Comment ({
|
|||
function DepthLimit ({ item }) {
|
||||
if (item.ncomments > 0) {
|
||||
return (
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a className='d-block p-3 font-weight-bold text-muted w-100 text-center'>view replies</a>
|
||||
<Link href={`/items/${item.id}`} className='d-block p-3 font-weight-bold text-muted w-100 text-center'>
|
||||
view replies
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import { MORE_FLAT_COMMENTS } from '../fragments/comments'
|
||||
import { CommentFlat, CommentSkeleton } from './comment'
|
||||
import MoreFooter from './more-footer'
|
||||
|
||||
export default function CommentsFlat ({ variables, query, destructureData, comments, cursor, ...props }) {
|
||||
const { data, fetchMore } = useQuery(query || MORE_FLAT_COMMENTS, {
|
||||
variables
|
||||
})
|
||||
|
||||
if (!data && !comments) {
|
||||
return <CommentsFlatSkeleton />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (destructureData) {
|
||||
({ comments, cursor } = destructureData(data))
|
||||
} else {
|
||||
({ moreFlatComments: { comments, cursor } } = data)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{comments.map(item =>
|
||||
<CommentFlat key={item.id} item={item} {...props} />
|
||||
)}
|
||||
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentsFlatSkeleton () {
|
||||
const comments = new Array(21).fill(null)
|
||||
|
||||
return (
|
||||
<div>{comments.map((_, i) => (
|
||||
<CommentSkeleton key={i} skeletonChildren={0} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,14 +1,15 @@
|
|||
import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import styles from './header.module.css'
|
||||
import { Nav, Navbar } from 'react-bootstrap'
|
||||
import { COMMENTS_QUERY } from '../fragments/items'
|
||||
import { COMMENTS } from '../fragments/comments'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import { defaultCommentSort } from '../lib/item'
|
||||
|
||||
export function CommentsHeader ({ handleSort, pinned, commentSats }) {
|
||||
const [sort, setSort] = useState(pinned ? 'recent' : 'hot')
|
||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||
const [sort, setSort] = useState(defaultCommentSort(pinned, bio, parentCreatedAt))
|
||||
|
||||
const getHandleClick = sort => {
|
||||
return () => {
|
||||
|
@ -60,19 +61,12 @@ export function CommentsHeader ({ handleSort, pinned, commentSats }) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function Comments ({ parentId, pinned, commentSats, comments, ...props }) {
|
||||
export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
|
||||
const client = useApolloClient()
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
try {
|
||||
document.querySelector(hash).scrollIntoView({ behavior: 'smooth' })
|
||||
} catch {}
|
||||
}
|
||||
}, [typeof window !== 'undefined' && window.location.hash])
|
||||
|
||||
const [loading, setLoading] = useState()
|
||||
const [getComments] = useLazyQuery(COMMENTS_QUERY, {
|
||||
fetchPolicy: 'network-only',
|
||||
fetchPolicy: 'cache-first',
|
||||
onCompleted: data => {
|
||||
client.writeFragment({
|
||||
id: `Item:${parentId}`,
|
||||
|
@ -97,7 +91,8 @@ export default function Comments ({ parentId, pinned, commentSats, comments, ...
|
|||
<>
|
||||
{comments.length
|
||||
? <CommentsHeader
|
||||
commentSats={commentSats} pinned={pinned} handleSort={sort => {
|
||||
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
|
||||
pinned={pinned} bio={bio} handleSort={sort => {
|
||||
setLoading(true)
|
||||
getComments({ variables: { id: parentId, sort } })
|
||||
}}
|
||||
|
|
|
@ -5,7 +5,7 @@ export default function SimpleCountdown ({ className, onComplete, date }) {
|
|||
<span className={className}>
|
||||
<Countdown
|
||||
date={date}
|
||||
renderer={props => <span className='text-monospace'> {props.formatted.minutes}:{props.formatted.seconds}</span>}
|
||||
renderer={props => <span className='text-monospace' suppressHydrationWarning> {props.formatted.minutes}:{props.formatted.seconds}</span>}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { getTheme, listenForThemeChange, setTheme } from '../public/dark'
|
||||
|
||||
export default function useDarkMode () {
|
||||
const [dark, setDark] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
const { user, dark } = getTheme()
|
||||
setDark({ user, dark })
|
||||
listenForThemeChange(setDark)
|
||||
}, [])
|
||||
|
||||
return [dark?.dark, () => {
|
||||
setTheme(!dark.dark)
|
||||
setDark({ user: true, dark: !dark.dark })
|
||||
}]
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
||||
import { useShowModal } from './modal'
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||
import { useRouter } from 'next/router'
|
||||
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
|
@ -36,16 +35,14 @@ export function DiscussionForm ({
|
|||
)
|
||||
|
||||
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
||||
${ITEM_FIELDS}
|
||||
query related($title: String!) {
|
||||
related(title: $title, minMatch: "75%", limit: 3) {
|
||||
items {
|
||||
...ItemFields
|
||||
${ITEM_FIELDS}
|
||||
query related($title: String!) {
|
||||
related(title: $title, minMatch: "75%", limit: 3) {
|
||||
items {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
}`)
|
||||
|
||||
const related = relatedData?.related?.items || []
|
||||
|
||||
|
@ -96,7 +93,6 @@ export function DiscussionForm ({
|
|||
topLevel
|
||||
label={<>{textLabel} <small className='text-muted ml-2'>optional</small></>}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
hint={editThreshold
|
||||
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Component } from 'react'
|
||||
import LayoutStatic from './layout-static'
|
||||
import { StaticLayout } from './layout'
|
||||
import styles from '../styles/404.module.css'
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
|
@ -25,10 +25,10 @@ class ErrorBoundary extends Component {
|
|||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
return (
|
||||
<LayoutStatic>
|
||||
<Image width='500' height='375' src='/floating.gif' fluid />
|
||||
<StaticLayout>
|
||||
<Image width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
|
||||
<h1 className={styles.fourZeroFour} style={{ fontSize: '48px' }}>something went wrong</h1>
|
||||
</LayoutStatic>
|
||||
</StaticLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
|
|||
const query = parentId
|
||||
? gql`{ itemRepetition(parentId: "${parentId}") }`
|
||||
: gql`{ itemRepetition }`
|
||||
const { data } = useQuery(query, { pollInterval: 1000 })
|
||||
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||
const repetition = data?.itemRepetition || 0
|
||||
const formik = useFormikContext()
|
||||
const boost = formik?.values?.boost || 0
|
||||
|
|
|
@ -10,14 +10,12 @@ const REWARDS = gql`
|
|||
}`
|
||||
|
||||
export default function Rewards () {
|
||||
const { data } = useQuery(REWARDS, { pollInterval: 60000, fetchPolicy: 'cache-and-network' })
|
||||
const { data } = useQuery(REWARDS, { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
|
||||
const total = data?.expectedRewards?.total
|
||||
|
||||
return (
|
||||
<Link href='/rewards' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
|
||||
</a>
|
||||
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import gql from 'graphql-tag'
|
||||
import { Container, OverlayTrigger, Popover } from 'react-bootstrap'
|
||||
import { CopyInput } from './form'
|
||||
import styles from './footer.module.css'
|
||||
import Texas from '../svgs/texas.svg'
|
||||
import Github from '../svgs/github-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import useDarkMode from 'use-dark-mode'
|
||||
import Sun from '../svgs/sun-fill.svg'
|
||||
import Moon from '../svgs/moon-fill.svg'
|
||||
import No from '../svgs/no.svg'
|
||||
|
@ -14,70 +11,7 @@ import Bolt from '../svgs/bolt.svg'
|
|||
import Amboss from '../svgs/amboss.svg'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Rewards from './footer-rewards'
|
||||
|
||||
// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
// if you update this you need to update /public/darkmode
|
||||
// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
const COLORS = {
|
||||
light: {
|
||||
body: '#f5f5f7',
|
||||
color: '#212529',
|
||||
navbarVariant: 'light',
|
||||
navLink: 'rgba(0, 0, 0, 0.55)',
|
||||
navLinkFocus: 'rgba(0, 0, 0, 0.7)',
|
||||
navLinkActive: 'rgba(0, 0, 0, 0.9)',
|
||||
borderColor: '#ced4da',
|
||||
inputBg: '#ffffff',
|
||||
inputDisabledBg: '#e9ecef',
|
||||
dropdownItemColor: 'rgba(0, 0, 0, 0.7)',
|
||||
dropdownItemColorHover: 'rgba(0, 0, 0, 0.9)',
|
||||
commentBg: 'rgba(0, 0, 0, 0.03)',
|
||||
clickToContextColor: 'rgba(0, 0, 0, 0.07)',
|
||||
brandColor: 'rgba(0, 0, 0, 0.9)',
|
||||
grey: '#707070',
|
||||
link: '#007cbe',
|
||||
toolbarActive: 'rgba(0, 0, 0, 0.10)',
|
||||
toolbarHover: 'rgba(0, 0, 0, 0.20)',
|
||||
toolbar: '#ffffff',
|
||||
quoteBar: 'rgb(206, 208, 212)',
|
||||
quoteColor: 'rgb(101, 103, 107)',
|
||||
linkHover: '#004a72',
|
||||
linkVisited: '#537587'
|
||||
},
|
||||
dark: {
|
||||
body: '#000000',
|
||||
inputBg: '#000000',
|
||||
inputDisabledBg: '#000000',
|
||||
navLink: 'rgba(255, 255, 255, 0.55)',
|
||||
navLinkFocus: 'rgba(255, 255, 255, 0.75)',
|
||||
navLinkActive: 'rgba(255, 255, 255, 0.9)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
dropdownItemColor: 'rgba(255, 255, 255, 0.7)',
|
||||
dropdownItemColorHover: 'rgba(255, 255, 255, 0.9)',
|
||||
commentBg: 'rgba(255, 255, 255, 0.04)',
|
||||
clickToContextColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#f8f9fa',
|
||||
brandColor: 'var(--primary)',
|
||||
grey: '#969696',
|
||||
link: '#2e99d1',
|
||||
toolbarActive: 'rgba(255, 255, 255, 0.10)',
|
||||
toolbarHover: 'rgba(255, 255, 255, 0.20)',
|
||||
toolbar: '#3e3f3f',
|
||||
quoteBar: 'rgb(158, 159, 163)',
|
||||
quoteColor: 'rgb(141, 144, 150)',
|
||||
linkHover: '#007cbe',
|
||||
linkVisited: '#56798E'
|
||||
}
|
||||
}
|
||||
|
||||
const handleThemeChange = (dark) => {
|
||||
const root = window.document.documentElement
|
||||
const colors = COLORS[dark ? 'dark' : 'light']
|
||||
Object.entries(colors).forEach(([varName, value]) => {
|
||||
const cssVarName = `--theme-${varName}`
|
||||
root.style.setProperty(cssVarName, value)
|
||||
})
|
||||
}
|
||||
import useDarkMode from './dark-mode'
|
||||
|
||||
const RssPopover = (
|
||||
<Popover>
|
||||
|
@ -179,33 +113,19 @@ const AnalyticsPopover = (
|
|||
visitors
|
||||
</a>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/stackers/day' passHref>
|
||||
<a className='nav-link p-0 d-inline-flex'>
|
||||
stackers
|
||||
</a>
|
||||
<Link href='/stackers/day' className='nav-link p-0 d-inline-flex'>
|
||||
stackers
|
||||
</Link>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
export default function Footer ({ noLinks }) {
|
||||
const query = gql`
|
||||
{
|
||||
connectAddress
|
||||
}
|
||||
`
|
||||
const { data } = useQuery(query, { fetchPolicy: 'cache-first' })
|
||||
export default function Footer ({ links = true }) {
|
||||
const [darkMode, darkModeToggle] = useDarkMode()
|
||||
|
||||
const darkMode = useDarkMode(false, {
|
||||
// set this so it doesn't try to use classes
|
||||
onChange: handleThemeChange
|
||||
})
|
||||
|
||||
const [mounted, setMounted] = useState()
|
||||
const [lightning, setLightning] = useState(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setLightning(localStorage.getItem('lnAnimate') || 'yes')
|
||||
}, [])
|
||||
|
||||
|
@ -219,7 +139,7 @@ export default function Footer ({ noLinks }) {
|
|||
}
|
||||
}
|
||||
|
||||
const DarkModeIcon = darkMode.value ? Sun : Moon
|
||||
const DarkModeIcon = darkMode ? Sun : Moon
|
||||
const LnIcon = lightning === 'yes' ? No : Bolt
|
||||
|
||||
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
||||
|
@ -227,13 +147,12 @@ export default function Footer ({ noLinks }) {
|
|||
return (
|
||||
<footer>
|
||||
<Container className='mb-3 mt-4'>
|
||||
{!noLinks &&
|
||||
{links &&
|
||||
<>
|
||||
{mounted &&
|
||||
<div className='mb-1'>
|
||||
<DarkModeIcon onClick={() => darkMode.toggle()} width={20} height={20} className='fill-grey theme' />
|
||||
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' />
|
||||
</div>}
|
||||
<div className='mb-1'>
|
||||
<DarkModeIcon onClick={darkModeToggle} width={20} height={20} className='fill-grey theme' suppressHydrationWarning />
|
||||
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' suppressHydrationWarning />
|
||||
</div>
|
||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||
<Rewards />
|
||||
</div>
|
||||
|
@ -263,38 +182,28 @@ export default function Footer ({ noLinks }) {
|
|||
</OverlayTrigger>
|
||||
</div>
|
||||
<div className='mb-2' style={{ fontWeight: 500 }}>
|
||||
<Link href='/faq' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
faq
|
||||
</a>
|
||||
<Link href='/faq' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
faq
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/guide' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
guide
|
||||
</a>
|
||||
<Link href='/guide' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
guide
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/story' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
story
|
||||
</a>
|
||||
<Link href='/story' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
story
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/changes' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
changes
|
||||
</a>
|
||||
<Link href='/changes' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
changes
|
||||
</Link>
|
||||
<span className='mx-2 text-muted'> \ </span>
|
||||
<Link href='/privacy' passHref>
|
||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
||||
privacy
|
||||
</a>
|
||||
<Link href='/privacy' className='nav-link p-0 p-0 d-inline-flex'>
|
||||
privacy
|
||||
</Link>
|
||||
</div>
|
||||
</>}
|
||||
{data &&
|
||||
{process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS &&
|
||||
<div
|
||||
className={`text-small mx-auto mb-2 ${styles.connect}`}
|
||||
>
|
||||
|
@ -304,7 +213,7 @@ export default function Footer ({ noLinks }) {
|
|||
groupClassName='mb-0 w-100'
|
||||
readOnly
|
||||
noForm
|
||||
placeholder={data.connectAddress}
|
||||
placeholder={process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS}
|
||||
/>
|
||||
<a
|
||||
href='https://amboss.space/node/03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02'
|
||||
|
@ -320,14 +229,14 @@ export default function Footer ({ noLinks }) {
|
|||
made in Austin<Texas className='ml-1' width={20} height={20} />
|
||||
<span className='ml-1'>by</span>
|
||||
<span>
|
||||
<Link href='/k00b' passHref>
|
||||
<a className='ml-1'>@k00b</a>
|
||||
<Link href='/k00b' className='ml-1'>
|
||||
@k00b
|
||||
</Link>
|
||||
<Link href='/kr' passHref>
|
||||
<a className='ml-1'>@kr</a>
|
||||
<Link href='/kr' className='ml-1'>
|
||||
@kr
|
||||
</Link>
|
||||
<Link href='/ekzyis' passHref>
|
||||
<a className='ml-1'>@ekzyis</a>
|
||||
<Link href='/ekzyis' className='ml-1'>
|
||||
@ekzyis
|
||||
</Link>
|
||||
</span>
|
||||
</small>
|
||||
|
|
|
@ -15,6 +15,7 @@ import { mdHas } from '../lib/md'
|
|||
import CloseIcon from '../svgs/close-line.svg'
|
||||
import { useLazyQuery } from '@apollo/client'
|
||||
import { USER_SEARCH } from '../fragments/users'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
export function SubmitButton ({
|
||||
children, variant, value, onClick, disabled, ...props
|
||||
|
@ -82,6 +83,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||
innerRef = innerRef || useRef(null)
|
||||
|
||||
props.as ||= TextareaAutosize
|
||||
props.rows ||= props.minRows || 6
|
||||
|
||||
useEffect(() => {
|
||||
!meta.value && setTab('write')
|
||||
}, [meta.value])
|
||||
|
@ -111,48 +115,53 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
<Markdown width={18} height={18} />
|
||||
</a>
|
||||
</Nav>
|
||||
<div className={tab !== 'write' ? 'd-none' : ''}>
|
||||
<InputInner
|
||||
{...props} onChange={(formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
if (setHasImgLink) {
|
||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
||||
}
|
||||
}}
|
||||
innerRef={innerRef}
|
||||
onKeyDown={(e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'k') {
|
||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'b') {
|
||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'i') {
|
||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||
e.preventDefault()
|
||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'Tab' && e.altKey) {
|
||||
e.preventDefault()
|
||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
}
|
||||
{tab === 'write'
|
||||
? (
|
||||
<div>
|
||||
<InputInner
|
||||
{...props} onChange={(formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
if (setHasImgLink) {
|
||||
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
||||
}
|
||||
}}
|
||||
innerRef={innerRef}
|
||||
onKeyDown={(e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
if (e.key === 'k') {
|
||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'b') {
|
||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||
e.preventDefault()
|
||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'i') {
|
||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||
e.preventDefault()
|
||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
if (e.key === 'Tab' && e.altKey) {
|
||||
e.preventDefault()
|
||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||
}
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}}
|
||||
/>
|
||||
</div>)
|
||||
: (
|
||||
<div className='form-group'>
|
||||
<div className={`${styles.text} form-control`}>
|
||||
<Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
|
||||
<div className={`${styles.text} form-control`}>
|
||||
{tab === 'preview' && <Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
)
|
||||
|
@ -300,7 +309,6 @@ function InputInner ({
|
|||
|
||||
export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
||||
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
||||
fetchPolicy: 'network-only',
|
||||
onCompleted: data => {
|
||||
setSuggestions({ array: data.searchUsers, index: 0 })
|
||||
}
|
||||
|
@ -476,10 +484,17 @@ export function Form ({
|
|||
)
|
||||
}
|
||||
|
||||
export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
|
||||
const [field, meta] = noForm ? [{}, {}] : useField(props)
|
||||
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
|
||||
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||
const formik = noForm ? null : useFormikContext()
|
||||
const invalid = meta.touched && meta.error
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideValue) {
|
||||
helpers.setValue(overrideValue)
|
||||
}
|
||||
}, [overrideValue])
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<BootstrapForm.Control
|
||||
|
|
|
@ -8,11 +8,11 @@ import Price from './price'
|
|||
import { useMe } from './me'
|
||||
import Head from 'next/head'
|
||||
import { signOut } from 'next-auth/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { randInRange } from '../lib/rand'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import NoteIcon from '../svgs/notification-4-fill.svg'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import LightningIcon from '../svgs/bolt.svg'
|
||||
import CowboyHat from './cowboy-hat'
|
||||
import { Form, Select } from './form'
|
||||
|
@ -20,10 +20,10 @@ import SearchIcon from '../svgs/search-line.svg'
|
|||
import BackArrow from '../svgs/arrow-left-line.svg'
|
||||
import { SUBS } from '../lib/constants'
|
||||
import { useLightning } from './lightning'
|
||||
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
if (!me) return null
|
||||
|
||||
return `${abbrNum(me.sats)}`
|
||||
}
|
||||
|
||||
|
@ -42,291 +42,257 @@ function Back () {
|
|||
return null
|
||||
}
|
||||
|
||||
export default function Header ({ sub }) {
|
||||
const router = useRouter()
|
||||
const [fired, setFired] = useState()
|
||||
const [topNavKey, setTopNavKey] = useState('')
|
||||
const [dropNavKey, setDropNavKey] = useState('')
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const me = useMe()
|
||||
|
||||
useEffect(() => {
|
||||
// there's always at least 2 on the split, e.g. '/' yields ['','']
|
||||
const path = router.asPath.split('?')[0]
|
||||
setPrefix(sub ? `/~${sub}` : '')
|
||||
setTopNavKey(path.split('/')[sub ? 2 : 1] ?? '')
|
||||
setDropNavKey(path.split('/').slice(sub ? 2 : 1).join('/'))
|
||||
setPath(path)
|
||||
}, [sub, router.asPath])
|
||||
|
||||
// const { data: subLatestPost } = useQuery(gql`
|
||||
// query subLatestPost($name: ID!) {
|
||||
// subLatestPost(name: $name)
|
||||
// }
|
||||
// `, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
|
||||
const { data: hasNewNotes } = useQuery(gql`
|
||||
{
|
||||
hasNewNotes
|
||||
}
|
||||
`, {
|
||||
function NotificationBell () {
|
||||
const { data } = useQuery(HAS_NOTIFICATIONS, {
|
||||
pollInterval: 30000,
|
||||
fetchPolicy: 'cache-and-network'
|
||||
nextFetchPolicy: 'cache-and-network'
|
||||
})
|
||||
// const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
||||
// useEffect(() => {
|
||||
// if (me) {
|
||||
// setLastCheckedJobs(me.lastCheckedJobs)
|
||||
// } else {
|
||||
// if (sub === 'jobs') {
|
||||
// localStorage.setItem('lastCheckedJobs', new Date().getTime())
|
||||
// }
|
||||
// setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
|
||||
// }
|
||||
// }, [sub])
|
||||
|
||||
const Corner = () => {
|
||||
if (me) {
|
||||
return (
|
||||
<div className='d-flex align-items-center ml-auto'>
|
||||
<Head>
|
||||
<link rel='shortcut icon' href={hasNewNotes?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||
</Head>
|
||||
<Link href='/notifications' passHref>
|
||||
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
||||
<NoteIcon height={22} width={22} className='theme' />
|
||||
{hasNewNotes?.hasNewNotes &&
|
||||
<span className={styles.notification}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</span>}
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
<div className='position-relative'>
|
||||
<NavDropdown
|
||||
className={styles.dropdown} title={
|
||||
<Nav.Link eventKey={me?.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
|
||||
{`@${me?.name}`}<CowboyHat user={me} />
|
||||
</Nav.Link>
|
||||
} alignRight
|
||||
>
|
||||
<Link href={'/' + me?.name} passHref>
|
||||
<NavDropdown.Item active={me?.name === dropNavKey}>
|
||||
profile
|
||||
{me && !me.bioId &&
|
||||
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</div>}
|
||||
</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href={'/' + me?.name + '/bookmarks'} passHref>
|
||||
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallet' passHref>
|
||||
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
|
||||
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<Link href='/referrals/month' passHref>
|
||||
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<div className='d-flex align-items-center'>
|
||||
<Link href='/settings' passHref>
|
||||
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
|
||||
</Link>
|
||||
</div>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
{me && !me.bioId &&
|
||||
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</span>}
|
||||
</div>
|
||||
{me &&
|
||||
<Nav.Item>
|
||||
<Link href='/wallet' passHref>
|
||||
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
if (!fired) {
|
||||
const strike = useLightning()
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
if (!localStorage.getItem('striked')) {
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
strike()
|
||||
localStorage.setItem('striked', 'yep')
|
||||
setFired(true)
|
||||
}
|
||||
}, randInRange(3000, 10000))
|
||||
}
|
||||
return () => { isMounted = false }
|
||||
}, [])
|
||||
}
|
||||
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||
<div className='ml-auto'>
|
||||
<Button
|
||||
className='align-items-center px-3 py-1 mr-2'
|
||||
id='signup'
|
||||
style={{ borderWidth: '2px' }}
|
||||
variant='outline-grey-darkmode'
|
||||
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||
>
|
||||
login
|
||||
</Button>
|
||||
<Button
|
||||
className='align-items-center pl-2 py-1 pr-3'
|
||||
style={{ borderWidth: '2px' }}
|
||||
id='login'
|
||||
onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||
>
|
||||
<LightningIcon
|
||||
width={17}
|
||||
height={17}
|
||||
className='mr-1'
|
||||
/>sign up
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
|
||||
// (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
|
||||
|
||||
const NavItems = ({ className }) => {
|
||||
return (
|
||||
<>
|
||||
<Nav.Item className={className}>
|
||||
<Form
|
||||
initial={{
|
||||
sub: sub || 'home'
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
groupClassName='mb-0'
|
||||
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
|
||||
name='sub'
|
||||
size='sm'
|
||||
items={['home', ...SUBS]}
|
||||
/>
|
||||
</Form>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/'} passHref>
|
||||
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/recent'} passHref>
|
||||
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
{sub !== 'jobs' &&
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/top/posts/day'} passHref>
|
||||
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
{/* <Nav.Item className={className}>
|
||||
<div className='position-relative'>
|
||||
<Link href='/~jobs' passHref>
|
||||
<Nav.Link active={sub === 'jobs'} className={styles.navLink}>
|
||||
jobs
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
{showJobIndicator &&
|
||||
<span className={styles.jobIndicator}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</span>}
|
||||
</div>
|
||||
</Nav.Item> */}
|
||||
{/* <Nav.Item className={`text-monospace nav-link mx-auto px-0 ${me?.name.length > 6 ? 'd-none d-lg-flex' : ''}`}>
|
||||
<Price />
|
||||
</Nav.Item> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PostItem = ({ className }) => {
|
||||
return me
|
||||
? (
|
||||
<Link href={prefix + '/post'} passHref>
|
||||
<a className={`${className} btn btn-md btn-primary px-3 py-1 `}>post</a>
|
||||
</Link>)
|
||||
: null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className='px-0'>
|
||||
<Navbar className='pb-0 pb-lg-2'>
|
||||
<Nav
|
||||
className={styles.navbarNav}
|
||||
activeKey={topNavKey}
|
||||
>
|
||||
<div className='d-flex align-items-center'>
|
||||
<Back />
|
||||
<Link href='/' passHref>
|
||||
<Navbar.Brand className={`${styles.brand} d-flex`}>
|
||||
SN
|
||||
</Navbar.Brand>
|
||||
</Link>
|
||||
</div>
|
||||
<NavItems className='d-none d-lg-flex mx-2' />
|
||||
<PostItem className='d-none d-lg-flex mx-2' />
|
||||
<Link href='/search' passHref>
|
||||
<Nav.Link eventKey='search' className='position-relative d-none d-lg-flex align-items-center pr-0 ml-2'>
|
||||
<SearchIcon className='theme' width={22} height={22} />
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
<Nav.Item className={`${styles.price} ml-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
|
||||
<Price className='nav-link text-monospace' />
|
||||
</Nav.Item>
|
||||
<Corner />
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Navbar className='pt-0 pb-2 d-lg-none'>
|
||||
<Nav
|
||||
className={`${styles.navbarNav}`}
|
||||
activeKey={topNavKey}
|
||||
>
|
||||
<NavItems className='mr-1' />
|
||||
<Link href='/search' passHref>
|
||||
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
|
||||
<SearchIcon className='theme' width={22} height={22} />
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
<PostItem className='mr-0 pr-0' />
|
||||
</Nav>
|
||||
</Navbar>
|
||||
</Container>
|
||||
<Head>
|
||||
<link rel='shortcut icon' href={data?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||
</Head>
|
||||
<Link href='/notifications' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
||||
<NoteIcon height={22} width={22} className='theme' />
|
||||
{data?.hasNewNotes &&
|
||||
<span className={styles.notification}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</span>}
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StackerCorner ({ dropNavKey }) {
|
||||
const me = useMe()
|
||||
|
||||
return (
|
||||
<div className='d-flex align-items-center ml-auto'>
|
||||
<NotificationBell />
|
||||
<div className='position-relative'>
|
||||
<NavDropdown
|
||||
className={styles.dropdown}
|
||||
title={
|
||||
<Nav.Link eventKey={me.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
|
||||
{`@${me.name}`}<CowboyHat user={me} />
|
||||
</Nav.Link>
|
||||
}
|
||||
alignRight
|
||||
>
|
||||
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||
<NavDropdown.Item active={me.name === dropNavKey}>
|
||||
profile
|
||||
{me && !me.bioId &&
|
||||
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</div>}
|
||||
</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||
<NavDropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
||||
</Link>
|
||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<Link href='/referrals/month' passHref legacyBehavior>
|
||||
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<div className='d-flex align-items-center'>
|
||||
<Link href='/settings' passHref legacyBehavior>
|
||||
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
|
||||
</Link>
|
||||
</div>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
{!me.bioId &&
|
||||
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</span>}
|
||||
</div>
|
||||
<Nav.Item>
|
||||
<Link href='/wallet' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LurkerCorner ({ path }) {
|
||||
const router = useRouter()
|
||||
const strike = useLightning()
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('striked')) {
|
||||
const to = setTimeout(() => {
|
||||
strike()
|
||||
localStorage.setItem('striked', 'yep')
|
||||
}, randInRange(3000, 10000))
|
||||
return () => clearTimeout(to)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async pathname => await router.push({
|
||||
pathname,
|
||||
query: { callbackUrl: window.location.origin + router.asPath }
|
||||
}), [router])
|
||||
|
||||
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||
<div className='ml-auto'>
|
||||
<Button
|
||||
className='align-items-center px-3 py-1 mr-2'
|
||||
id='signup'
|
||||
style={{ borderWidth: '2px' }}
|
||||
variant='outline-grey-darkmode'
|
||||
onClick={() => handleLogin('/login')}
|
||||
>
|
||||
login
|
||||
</Button>
|
||||
<Button
|
||||
className='align-items-center pl-2 py-1 pr-3'
|
||||
style={{ borderWidth: '2px' }}
|
||||
id='login'
|
||||
onClick={() => handleLogin('/signup')}
|
||||
>
|
||||
<LightningIcon
|
||||
width={17}
|
||||
height={17}
|
||||
className='mr-1'
|
||||
/>sign up
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
function NavItems ({ className, sub, prefix }) {
|
||||
const router = useRouter()
|
||||
sub ||= 'home'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav.Item className={className}>
|
||||
<Form
|
||||
initial={{ sub }}
|
||||
>
|
||||
<Select
|
||||
groupClassName='mb-0'
|
||||
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
|
||||
name='sub'
|
||||
size='sm'
|
||||
overrideValue={sub}
|
||||
items={['home', ...SUBS]}
|
||||
/>
|
||||
</Form>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/recent'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
{sub !== 'jobs' &&
|
||||
<Nav.Item className={className}>
|
||||
<Link href={prefix + '/top/posts/day'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PostItem ({ className, prefix }) {
|
||||
const me = useMe()
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
|
||||
post
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Header ({ sub }) {
|
||||
const router = useRouter()
|
||||
const path = router.asPath.split('?')[0]
|
||||
const prefix = sub ? `/~${sub}` : ''
|
||||
const topNavKey = path.split('/')[sub ? 2 : 1] ?? ''
|
||||
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
|
||||
const me = useMe()
|
||||
|
||||
return (
|
||||
<Container as='header' className='px-0'>
|
||||
<Navbar className='pb-0 pb-lg-2'>
|
||||
<Nav
|
||||
className={styles.navbarNav}
|
||||
activeKey={topNavKey}
|
||||
>
|
||||
<div className='d-flex align-items-center'>
|
||||
<Back />
|
||||
<Link href='/' passHref legacyBehavior>
|
||||
<Navbar.Brand className={`${styles.brand} d-flex`}>
|
||||
SN
|
||||
</Navbar.Brand>
|
||||
</Link>
|
||||
</div>
|
||||
<NavItems className='d-none d-lg-flex mx-2' prefix={prefix} sub={sub} />
|
||||
<PostItem className='d-none d-lg-flex mx-2' prefix={prefix} />
|
||||
<Link href={prefix + '/search'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='search' className='position-relative d-none d-lg-flex align-items-center pr-0 ml-2'>
|
||||
<SearchIcon className='theme' width={22} height={22} />
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
<Nav.Item className={`${styles.price} ml-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
|
||||
<Price className='nav-link text-monospace' />
|
||||
</Nav.Item>
|
||||
{me ? <StackerCorner dropNavKey={dropNavKey} /> : <LurkerCorner path={path} />}
|
||||
</Nav>
|
||||
</Navbar>
|
||||
<Navbar className='pt-0 pb-2 d-lg-none'>
|
||||
<Nav
|
||||
className={`${styles.navbarNav}`}
|
||||
activeKey={topNavKey}
|
||||
>
|
||||
<NavItems className='mr-1' prefix={prefix} sub={sub} />
|
||||
<Link href={prefix + '/search'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
|
||||
<SearchIcon className='theme' width={22} height={22} />
|
||||
</Nav.Link>
|
||||
</Link>
|
||||
<PostItem className='mr-0 pr-0' prefix={prefix} />
|
||||
</Nav>
|
||||
</Navbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeaderStatic () {
|
||||
return (
|
||||
<Container className='px-sm-0'>
|
||||
<Container as='header' className='px-sm-0'>
|
||||
<Navbar className='pb-0 pb-lg-1'>
|
||||
<Nav
|
||||
className={styles.navbarNav}
|
||||
>
|
||||
<div className='d-flex align-items-center'>
|
||||
<Back />
|
||||
<Link href='/' passHref>
|
||||
<Link href='/' passHref legacyBehavior>
|
||||
<Navbar.Brand className={`${styles.brand}`}>
|
||||
SN
|
||||
</Navbar.Brand>
|
||||
</Link>
|
||||
|
||||
<Link href='/search' passHref>
|
||||
<Link href='/search' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
|
||||
<SearchIcon className='theme' width={22} height={22} />
|
||||
</Nav.Link>
|
||||
|
|
|
@ -1,28 +1,16 @@
|
|||
import { useState } from 'react'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
import InfoIcon from '../svgs/information-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export default function Info ({ children, iconClassName = 'fill-theme-color' }) {
|
||||
const [info, setInfo] = useState()
|
||||
const showModal = useShowModal()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
show={info}
|
||||
onHide={() => setInfo(false)}
|
||||
>
|
||||
<div className='modal-close' onClick={() => setInfo(false)}>X</div>
|
||||
<Modal.Body>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<InfoIcon
|
||||
width={18} height={18} className={`${iconClassName} pointer ml-1`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setInfo(true)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<InfoIcon
|
||||
width={18} height={18} className={`${iconClassName} pointer ml-1`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
showModal(onClose => children)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,12 +5,13 @@ import Comment from './comment'
|
|||
import Text, { ZoomableImage } from './text'
|
||||
import Comments from './comments'
|
||||
import styles from '../styles/item.module.css'
|
||||
import itemStyles from './item.module.css'
|
||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||
import { useMe } from './me'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||
import YouTube from 'react-youtube'
|
||||
import useDarkMode from 'use-dark-mode'
|
||||
import useDarkMode from './dark-mode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Poll from './poll'
|
||||
import { commentsViewed } from '../lib/new-comments'
|
||||
|
@ -61,7 +62,7 @@ function TweetSkeleton () {
|
|||
}
|
||||
|
||||
function ItemEmbed ({ item }) {
|
||||
const darkMode = useDarkMode()
|
||||
const [darkMode] = useDarkMode()
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
|
@ -69,7 +70,7 @@ function ItemEmbed ({ item }) {
|
|||
if (twitter?.groups?.id) {
|
||||
return (
|
||||
<div className={`${styles.twitterContainer} ${show ? '' : styles.twitterContained}`}>
|
||||
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ width: '550px', theme: darkMode.value ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
|
||||
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ width: '550px', theme: darkMode ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||
show full tweet
|
||||
|
@ -104,8 +105,8 @@ function FwdUser ({ user }) {
|
|||
return (
|
||||
<div className={styles.other}>
|
||||
100% of zaps are forwarded to{' '}
|
||||
<Link href={`/${user.name}`} passHref>
|
||||
<a>@{user.name}</a>
|
||||
<Link href={`/${user.name}`}>
|
||||
@{user.name}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
@ -119,10 +120,11 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||
item={item}
|
||||
full
|
||||
right={
|
||||
<>
|
||||
<Share item={item} />
|
||||
<Toc text={item.text} />
|
||||
</>
|
||||
!noReply &&
|
||||
<>
|
||||
<Share item={item} />
|
||||
<Toc text={item.text} />
|
||||
</>
|
||||
}
|
||||
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />}
|
||||
{...props}
|
||||
|
@ -158,26 +160,37 @@ function ItemText ({ item }) {
|
|||
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.searchText || item.text}</Text>
|
||||
}
|
||||
|
||||
export default function ItemFull ({ item, bio, ...props }) {
|
||||
export default function ItemFull ({ item, bio, rank, ...props }) {
|
||||
useEffect(() => {
|
||||
commentsViewed(item)
|
||||
}, [item.lastCommentAt])
|
||||
|
||||
return (
|
||||
<RootProvider root={item.root || item}>
|
||||
{item.parentId
|
||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||
: (
|
||||
<div className='mt-1'>{
|
||||
<>
|
||||
{rank
|
||||
? (
|
||||
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
|
||||
{rank}
|
||||
</div>)
|
||||
: <div />}
|
||||
<RootProvider root={item.root || item}>
|
||||
{item.parentId
|
||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||
: (
|
||||
<div className='mt-1'>{
|
||||
bio
|
||||
? <BioItem item={item} {...props} />
|
||||
: <TopLevelItem item={item} {...props} />
|
||||
}
|
||||
</div>)}
|
||||
{item.comments &&
|
||||
<div className={styles.comments}>
|
||||
<Comments parentId={item.id} pinned={item.position} commentSats={item.commentSats} comments={item.comments} />
|
||||
</div>}
|
||||
</RootProvider>
|
||||
</div>)}
|
||||
{item.comments &&
|
||||
<div className={styles.comments}>
|
||||
<Comments
|
||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||
/>
|
||||
</div>}
|
||||
</RootProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -41,43 +41,39 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
|
|||
<span>{abbrNum(item.boost)} boost</span>
|
||||
<span> \ </span>
|
||||
</>}
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={`${item.commentSats} sats`} className='text-reset'>
|
||||
{item.ncomments} {commentsText || 'comments'}
|
||||
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
||||
</a>
|
||||
<Link href={`/items/${item.id}`} title={`${item.commentSats} sats`} className='text-reset'>
|
||||
{item.ncomments} {commentsText || 'comments'}
|
||||
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
||||
</Link>
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`} passHref>
|
||||
<a className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</a>
|
||||
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
</Link>
|
||||
{item.prior &&
|
||||
<>
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.prior}`} passHref>
|
||||
<a className='text-reset'>yesterday</a>
|
||||
<Link href={`/items/${item.prior}`} className='text-reset'>
|
||||
yesterday
|
||||
</Link>
|
||||
</>}
|
||||
</span>
|
||||
{item.subName &&
|
||||
<Link href={`/~${item.subName}`}>
|
||||
<a>{' '}<Badge className={styles.newComment} variant={null}>{item.subName}</Badge></a>
|
||||
{' '}<Badge className={styles.newComment} variant={null}>{item.subName}</Badge>
|
||||
</Link>}
|
||||
{(item.outlawed && !item.mine &&
|
||||
<Link href='/outlawed'>
|
||||
<a>{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge></a>
|
||||
<Link href='/recent/outlawed'>
|
||||
{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge>
|
||||
</Link>) ||
|
||||
(item.freebie && !item.mine &&
|
||||
<Link href='/freebie'>
|
||||
<a>{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge></a>
|
||||
(item.freebie &&
|
||||
<Link href='/recent/freebies'>
|
||||
{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{canEdit && !item.deletedAt &&
|
||||
|
@ -101,11 +97,9 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
|
|||
{me && <BookmarkDropdownItem item={item} />}
|
||||
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
|
||||
{item.otsHash &&
|
||||
<Dropdown.Item>
|
||||
<Link passHref href={`/items/${item.id}/ots`}>
|
||||
<a className='text-reset'>ots timestamp</a>
|
||||
</Link>
|
||||
</Dropdown.Item>}
|
||||
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
|
||||
ots timestamp
|
||||
</Link>}
|
||||
{me && !item.meSats && !item.position && !item.meDontLike &&
|
||||
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
||||
{item.mine && !item.position && !item.deletedAt &&
|
||||
|
|
|
@ -21,22 +21,18 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
</div>)
|
||||
: <div />}
|
||||
<div className={`${styles.item}`}>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a>
|
||||
<Image
|
||||
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||
/>
|
||||
</a>
|
||||
<Link href={`/items/${item.id}`}>
|
||||
<Image
|
||||
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||
/>
|
||||
</Link>
|
||||
<div className={`${styles.hunk} align-self-center mb-0`}>
|
||||
<div className={`${styles.main} flex-wrap d-inline`}>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a className={`${styles.title} text-reset mr-2`}>
|
||||
{item.searchTitle
|
||||
? <SearchTitle title={item.searchTitle} />
|
||||
: (
|
||||
<>{item.title}</>)}
|
||||
</a>
|
||||
<Link href={`/items/${item.id}`} className={`${styles.title} text-reset mr-2`}>
|
||||
{item.searchTitle
|
||||
? <SearchTitle title={item.searchTitle} />
|
||||
: (
|
||||
<>{item.title}</>)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`${styles.other}`}>
|
||||
|
@ -52,14 +48,12 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
<wbr />
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`} passHref>
|
||||
<a className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||
</a>
|
||||
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
|
||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||
</Link>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset'>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
</Link>
|
||||
</span>
|
||||
{item.mine &&
|
||||
|
@ -67,10 +61,8 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
<>
|
||||
<wbr />
|
||||
<span> \ </span>
|
||||
<Link href={`/items/${item.id}/edit`} passHref>
|
||||
<a className='text-reset'>
|
||||
edit
|
||||
</a>
|
||||
<Link href={`/items/${item.id}/edit`} className='text-reset'>
|
||||
edit
|
||||
</Link>
|
||||
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
||||
</>)}
|
||||
|
|
|
@ -19,7 +19,7 @@ export function SearchTitle ({ title }) {
|
|||
})
|
||||
}
|
||||
|
||||
export default function Item ({ item, rank, belowTitle, right, full, children }) {
|
||||
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) {
|
||||
const titleRef = useRef()
|
||||
const [pendingSats, setPendingSats] = useState(0)
|
||||
|
||||
|
@ -33,24 +33,22 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
|
|||
{rank}
|
||||
</div>)
|
||||
: <div />}
|
||||
<div className={styles.item}>
|
||||
<div className={`${styles.item} ${siblingComments ? 'pt-2' : ''}`}>
|
||||
{item.position
|
||||
? <Pin width={24} height={24} className={styles.pin} />
|
||||
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
|
||||
<div className={styles.hunk}>
|
||||
<div className={`${styles.main} flex-wrap`}>
|
||||
<Link href={`/items/${item.id}`} passHref>
|
||||
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
||||
{item.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ml-1' height={14} width={14} /></span>}
|
||||
{item.bounty > 0 &&
|
||||
<span className={styles.icon}>
|
||||
<ActionTooltip notForm overlayText={`${abbrNum(item.bounty)} ${item.bountyPaidTo?.length ? 'sats paid' : 'sats bounty'}`}>
|
||||
<BountyIcon className={`${styles.bountyIcon} ${item.bountyPaidTo?.length ? 'fill-success' : 'fill-grey'}`} height={16} width={16} />
|
||||
</ActionTooltip>
|
||||
</span>}
|
||||
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
|
||||
</a>
|
||||
<Link href={`/items/${item.id}`} ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
||||
{item.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ml-1' height={14} width={14} /></span>}
|
||||
{item.bounty > 0 &&
|
||||
<span className={styles.icon}>
|
||||
<ActionTooltip notForm overlayText={`${abbrNum(item.bounty)} ${item.bountyPaidTo?.length ? 'sats paid' : 'sats bounty'}`}>
|
||||
<BountyIcon className={`${styles.bountyIcon} ${item.bountyPaidTo?.length ? 'fill-success' : 'fill-grey'}`} height={16} width={16} />
|
||||
</ActionTooltip>
|
||||
</span>}
|
||||
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
|
||||
</Link>
|
||||
{item.url && !image &&
|
||||
<>
|
||||
|
|
|
@ -70,7 +70,7 @@ a.link:visited {
|
|||
display: flex;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
padding-bottom: .45rem;
|
||||
padding-bottom: .5rem;
|
||||
}
|
||||
|
||||
.item .companyImage {
|
||||
|
@ -120,7 +120,7 @@ a.link:visited {
|
|||
|
||||
.rank {
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
margin-top: .25rem;
|
||||
display: flex;
|
||||
color: var(--theme-grey);
|
||||
font-size: 90%;
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { Fragment } from 'react'
|
||||
import { CommentFlat } from './comment'
|
||||
import Item from './item'
|
||||
import ItemJob from './item-job'
|
||||
import { ItemsSkeleton } from './items'
|
||||
import styles from './items.module.css'
|
||||
import MoreFooter from './more-footer'
|
||||
|
||||
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={item.id}>
|
||||
{item.parentId
|
||||
? (
|
||||
<><div />
|
||||
<div className='pb-1 mb-1'>
|
||||
<CommentFlat item={item} noReply includeParent clickToContext />
|
||||
</div>
|
||||
</>)
|
||||
: (item.isJob
|
||||
? <ItemJob item={item} rank={rank && i + 1} />
|
||||
: <Item item={item} rank={rank && i + 1} />)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<MoreFooter
|
||||
cursor={cursor} fetchMore={fetchMore}
|
||||
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -2,57 +2,63 @@ import { useQuery } from '@apollo/client'
|
|||
import Item, { ItemSkeleton } from './item'
|
||||
import ItemJob from './item-job'
|
||||
import styles from './items.module.css'
|
||||
import { ITEMS } from '../fragments/items'
|
||||
import MoreFooter from './more-footer'
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, useCallback, useMemo } from 'react'
|
||||
import { CommentFlat } from './comment'
|
||||
import { SUB_ITEMS } from '../fragments/subs'
|
||||
import { LIMIT } from '../lib/cursor'
|
||||
import ItemFull from './item-full'
|
||||
|
||||
export default function Items ({ variables = {}, query, destructureData, rank, items, pins, cursor }) {
|
||||
const { data, fetchMore } = useQuery(query || ITEMS, { variables })
|
||||
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
|
||||
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
|
||||
const Foooter = Footer || MoreFooter
|
||||
|
||||
if (!data && !items) {
|
||||
return <ItemsSkeleton rank={rank} />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const { items, pins, cursor } = useMemo(() => {
|
||||
if (!data && !ssrData) return {}
|
||||
if (destructureData) {
|
||||
({ items, pins, cursor } = destructureData(data))
|
||||
return destructureData(data || ssrData)
|
||||
} else {
|
||||
({ items: { items, pins, cursor } } = data)
|
||||
return data?.items || ssrData?.items
|
||||
}
|
||||
}
|
||||
}, [data, ssrData])
|
||||
|
||||
const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {})
|
||||
const pinMap = useMemo(() =>
|
||||
pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins])
|
||||
|
||||
const Skeleton = useCallback(() =>
|
||||
<ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} />, [rank, items])
|
||||
|
||||
if (!ssrData && !data) {
|
||||
return <Skeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{items.map((item, i) => (
|
||||
{items.filter(filter).map((item, i) => (
|
||||
<Fragment key={item.id}>
|
||||
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
|
||||
{item.parentId
|
||||
? <><div /><div className='pb-3'><CommentFlat item={item} noReply includeParent /></div></>
|
||||
? <CommentFlat item={item} rank={rank && i + 1} noReply includeParent clickToContext />
|
||||
: (item.isJob
|
||||
? <ItemJob item={item} rank={rank && i + 1} />
|
||||
: (item.title
|
||||
? <Item item={item} rank={rank && i + 1} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<CommentFlat item={item} noReply includeParent clickToContext />
|
||||
</div>)))}
|
||||
: (item.searchText
|
||||
? <ItemFull item={item} rank={rank && i + 1} noReply siblingComments={variables.includeComments} />
|
||||
: <Item item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<MoreFooter
|
||||
cursor={cursor} fetchMore={fetchMore}
|
||||
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
|
||||
<Foooter
|
||||
cursor={cursor} fetchMore={fetchMore} noMoreText={noMoreText}
|
||||
count={items?.length}
|
||||
Skeleton={Skeleton}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemsSkeleton ({ rank, startRank = 0 }) {
|
||||
const items = new Array(21).fill(null)
|
||||
export function ItemsSkeleton ({ rank, startRank = 0, limit = LIMIT }) {
|
||||
const items = new Array(limit).fill(null)
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Info from './info'
|
||||
|
@ -133,7 +132,6 @@ export default function JobForm ({ item, sub }) {
|
|||
topLevel
|
||||
label='description'
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
required
|
||||
/>
|
||||
|
@ -170,7 +168,7 @@ function PromoteJob ({ item, sub, storageKeyPrefix }) {
|
|||
query AuctionPosition($id: ID, $bid: Int!) {
|
||||
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
||||
}`,
|
||||
{ fetchPolicy: 'network-only' })
|
||||
{ fetchPolicy: 'cache-and-network' })
|
||||
const position = data?.auctionPosition
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -260,7 +258,7 @@ function StatusControl ({ item }) {
|
|||
<div className='p-3'>
|
||||
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
||||
{item.status === 'NOSATS' &&
|
||||
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' passHref><a className='text-reset text-underline'>fund your wallet</a></Link> or reduce bid to continue promoting your job</Alert>}
|
||||
<Alert variant='warning'>your promotion ran out of sats. <Link href='/wallet?type=fund' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
|
||||
<StatusComp />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import Layout from './layout'
|
||||
import styles from './layout-center.module.css'
|
||||
|
||||
export default function LayoutCenter ({ children, footerLinks, ...props }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Layout noContain noFooterLinks={!footerLinks} {...props}>
|
||||
<div className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import Footer from './footer'
|
||||
import { HeaderStatic } from './header'
|
||||
import styles from './layout-center.module.css'
|
||||
|
||||
export default function LayoutStatic ({ children, ...props }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<HeaderStatic />
|
||||
<div className={`${styles.content} pt-5`}>
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,29 +1,60 @@
|
|||
import Header from './header'
|
||||
import Header, { HeaderStatic } from './header'
|
||||
import Container from 'react-bootstrap/Container'
|
||||
import { LightningProvider } from './lightning'
|
||||
import Footer from './footer'
|
||||
import Seo from './seo'
|
||||
import Seo, { SeoSearch } from './seo'
|
||||
import Search from './search'
|
||||
import styles from './layout.module.css'
|
||||
|
||||
export default function Layout ({
|
||||
sub, noContain, noFooter, noFooterLinks,
|
||||
containClassName, noSeo, children, search
|
||||
sub, contain = true, footer = true, footerLinks = true,
|
||||
containClassName = '', seo = true, item, user, children
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!noSeo && <Seo sub={sub} />}
|
||||
<LightningProvider>
|
||||
<Header sub={sub} />
|
||||
{noContain
|
||||
? children
|
||||
: (
|
||||
<Container className={`px-sm-0 ${containClassName || ''}`}>
|
||||
{children}
|
||||
</Container>
|
||||
)}
|
||||
{!noFooter && <Footer noLinks={noFooterLinks} />}
|
||||
{!noContain && search && <Search sub={sub} />}
|
||||
</LightningProvider>
|
||||
{seo && <Seo sub={sub} item={item} user={user} />}
|
||||
<Header sub={sub} />
|
||||
{contain
|
||||
? (
|
||||
<Container as='main' className={`px-sm-0 ${containClassName}`}>
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
: children}
|
||||
{footer && <Footer links={footerLinks} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchLayout ({ sub, children, ...props }) {
|
||||
return (
|
||||
<Layout sub={sub} seo={false} footer={false} {...props}>
|
||||
<SeoSearch sub={sub} />
|
||||
{children}
|
||||
<Search />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export function StaticLayout ({ children, footer = true, footerLinks, ...props }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<HeaderStatic />
|
||||
<main className={`${styles.content} pt-5`}>
|
||||
{children}
|
||||
</main>
|
||||
{footer && <Footer links={footerLinks} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CenterLayout ({ children, ...props }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Layout contain={false} {...props}>
|
||||
<main className={styles.content}>
|
||||
{children}
|
||||
</main>
|
||||
</Layout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
|
|||
k1
|
||||
}
|
||||
}`
|
||||
const { data } = useQuery(query, { pollInterval: 1000 })
|
||||
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||
|
||||
if (data && data.lnAuth.pubkey) {
|
||||
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
import React, { useRef, useEffect, useContext } from 'react'
|
||||
import { randInRange } from '../lib/rand'
|
||||
|
||||
export const LightningContext = React.createContext({
|
||||
bolts: 0,
|
||||
strike: () => {}
|
||||
})
|
||||
export const LightningContext = React.createContext(() => {})
|
||||
|
||||
export class LightningProvider extends React.Component {
|
||||
state = {
|
||||
bolts: 0,
|
||||
strike: (repeat) => {
|
||||
const should = localStorage.getItem('lnAnimate') || 'yes'
|
||||
if (should === 'yes') {
|
||||
this.setState(state => {
|
||||
return {
|
||||
...this.state,
|
||||
bolts: this.state.bolts + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
bolts: []
|
||||
}
|
||||
|
||||
strike = () => {
|
||||
const should = localStorage.getItem('lnAnimate') || 'yes'
|
||||
if (should === 'yes') {
|
||||
this.setState(state => {
|
||||
return {
|
||||
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
unstrike = (index) => {
|
||||
this.setState(state => {
|
||||
const bolts = [...state.bolts]
|
||||
bolts[index] = null
|
||||
return { bolts }
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
const { state, props: { children } } = this
|
||||
const { props: { children } } = this
|
||||
return (
|
||||
<LightningContext.Provider value={state}>
|
||||
{new Array(this.state.bolts).fill(null).map((_, i) => <Lightning key={i} />)}
|
||||
<LightningContext.Provider value={this.strike}>
|
||||
{this.state.bolts}
|
||||
{children}
|
||||
</LightningContext.Provider>
|
||||
)
|
||||
|
@ -35,31 +40,33 @@ export class LightningProvider extends React.Component {
|
|||
|
||||
export const LightningConsumer = LightningContext.Consumer
|
||||
export function useLightning () {
|
||||
const { strike } = useContext(LightningContext)
|
||||
return strike
|
||||
return useContext(LightningContext)
|
||||
}
|
||||
|
||||
export function Lightning () {
|
||||
export function Lightning ({ onDone }) {
|
||||
const canvasRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (canvas.bolt) return
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
const bolt = new Bolt(context, {
|
||||
canvas.bolt = new Bolt(context, {
|
||||
startPoint: [Math.random() * (canvas.width * 0.5) + (canvas.width * 0.25), 0],
|
||||
length: canvas.height,
|
||||
speed: 100,
|
||||
spread: 30,
|
||||
branches: 20
|
||||
branches: 20,
|
||||
onDone
|
||||
})
|
||||
bolt.draw()
|
||||
canvas.bolt.draw()
|
||||
}, [])
|
||||
|
||||
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 0, pointerEvents: 'none' }} />
|
||||
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 100, pointerEvents: 'none' }} />
|
||||
}
|
||||
|
||||
function Bolt (ctx, options) {
|
||||
|
@ -79,12 +86,6 @@ function Bolt (ctx, options) {
|
|||
this.lastAngle = this.options.angle
|
||||
this.children = []
|
||||
|
||||
const radians = this.options.angle * Math.PI / 180
|
||||
this.endPoint = [
|
||||
this.options.startPoint[0] + Math.cos(radians) * this.options.length,
|
||||
this.options.startPoint[1] + Math.sin(radians) * this.options.length
|
||||
]
|
||||
|
||||
ctx.shadowColor = 'rgba(250, 218, 94, 1)'
|
||||
ctx.shadowBlur = 5
|
||||
ctx.shadowOffsetX = 0
|
||||
|
@ -92,6 +93,7 @@ function Bolt (ctx, options) {
|
|||
ctx.fillStyle = 'rgba(250, 250, 250, 1)'
|
||||
ctx.strokeStyle = 'rgba(250, 218, 94, 1)'
|
||||
ctx.lineWidth = this.options.lineWidth
|
||||
|
||||
this.draw = (isChild) => {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(this.point[0], this.point[1])
|
||||
|
@ -110,9 +112,6 @@ function Bolt (ctx, options) {
|
|||
Math.pow(this.point[1] - this.options.startPoint[1], 2)
|
||||
)
|
||||
|
||||
// make skinnier?
|
||||
// ctx.lineWidth = ctx.lineWidth * 0.98
|
||||
|
||||
if (randInRange(0, 99) < this.options.branches && this.children.length < this.options.maxBranches) {
|
||||
this.children.push(new Bolt(ctx, {
|
||||
startPoint: [this.point[0], this.point[1]],
|
||||
|
@ -146,6 +145,7 @@ function Bolt (ctx, options) {
|
|||
ctx.canvas.style.opacity -= 0.04
|
||||
if (ctx.canvas.style.opacity <= 0) {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||
this.options.onDone()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -29,30 +29,25 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
title
|
||||
unshorted
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
}`)
|
||||
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
|
||||
${ITEM_FIELDS}
|
||||
query Dupes($url: String!) {
|
||||
dupes(url: $url) {
|
||||
...ItemFields
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'network-only',
|
||||
${ITEM_FIELDS}
|
||||
query Dupes($url: String!) {
|
||||
dupes(url: $url) {
|
||||
...ItemFields
|
||||
}
|
||||
}`, {
|
||||
onCompleted: () => setPostDisabled(false)
|
||||
})
|
||||
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
||||
${ITEM_FIELDS}
|
||||
query related($title: String!) {
|
||||
related(title: $title, minMatch: "75%", limit: 3) {
|
||||
items {
|
||||
...ItemFields
|
||||
${ITEM_FIELDS}
|
||||
query related($title: String!) {
|
||||
related(title: $title, minMatch: "75%", limit: 3) {
|
||||
items {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
}`)
|
||||
|
||||
const related = []
|
||||
for (const item of relatedData?.related?.items || []) {
|
||||
|
|
|
@ -15,14 +15,15 @@ export default function LoginButton ({ text, type, className, onClick }) {
|
|||
Icon = GithubIcon
|
||||
variant = 'dark'
|
||||
break
|
||||
case 'lightning':
|
||||
Icon = LightningIcon
|
||||
variant = 'primary'
|
||||
break
|
||||
case 'slashtags':
|
||||
Icon = SlashtagsIcon
|
||||
variant = 'grey-medium'
|
||||
break
|
||||
case 'lightning':
|
||||
default:
|
||||
Icon = LightningIcon
|
||||
variant = 'primary'
|
||||
break
|
||||
}
|
||||
|
||||
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
|
||||
|
|
|
@ -7,7 +7,7 @@ export const MeContext = React.createContext({
|
|||
})
|
||||
|
||||
export function MeProvider ({ me, children }) {
|
||||
const { data } = useQuery(ME, { pollInterval: 1000, fetchPolicy: 'cache-and-network' })
|
||||
const { data } = useQuery(ME, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||
|
||||
const contextValue = {
|
||||
me: data?.me || me
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
|
||||
export default function ModalButton ({ children, clicker }) {
|
||||
const [show, setShow] = useState()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
show={show}
|
||||
onHide={() => setShow(false)}
|
||||
>
|
||||
<div className='modal-close' onClick={() => setShow(false)}>X</div>
|
||||
<Modal.Body>
|
||||
{show && children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<div className='pointer' onClick={() => setShow(true)}>{clicker}</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText }) {
|
||||
export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, noMoreText = 'GENESIS' }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
|
@ -28,9 +29,24 @@ export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText })
|
|||
)
|
||||
} else {
|
||||
Footer = () => (
|
||||
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{noMoreText || 'GENESIS'}</div>
|
||||
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{count === 0 ? 'EMPTY' : noMoreText}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className='d-flex justify-content-center mt-3 mb-1'><Footer /></div>
|
||||
}
|
||||
|
||||
export function NavigateFooter ({ cursor, count, fetchMore, href, text, noMoreText = 'NO MORE' }) {
|
||||
let Footer
|
||||
if (cursor) {
|
||||
Footer = () => (
|
||||
<Link href={href} className='text-reset text-muted font-weight-bold'>{text}</Link>
|
||||
)
|
||||
} else {
|
||||
Footer = () => (
|
||||
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{count === 0 ? 'EMPTY' : noMoreText}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className='d-flex justify-content-start my-1'><Footer /></div>
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useApolloClient, useQuery } from '@apollo/client'
|
||||
import Comment, { CommentSkeleton } from './comment'
|
||||
import Item from './item'
|
||||
import ItemJob from './item-job'
|
||||
import { NOTIFICATIONS } from '../fragments/notifications'
|
||||
import { useRouter } from 'next/router'
|
||||
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
|
||||
import MoreFooter from './more-footer'
|
||||
import Invite from './invite'
|
||||
import { ignoreClick } from '../lib/clicks'
|
||||
|
@ -20,6 +19,7 @@ import { Alert } from 'react-bootstrap'
|
|||
import styles from './notifications.module.css'
|
||||
import { useServiceWorker } from './serviceworker'
|
||||
import { Checkbox, Form } from './form'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
function Notification ({ n }) {
|
||||
switch (n.__typename) {
|
||||
|
@ -37,39 +37,47 @@ function Notification ({ n }) {
|
|||
return null
|
||||
}
|
||||
|
||||
function NotificationLayout ({ children, onClick }) {
|
||||
function NotificationLayout ({ children, href, as }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div
|
||||
className='clickToContext' onClick={(e) => {
|
||||
if (ignoreClick(e)) return
|
||||
onClick?.(e)
|
||||
}}
|
||||
className='clickToContext'
|
||||
onClick={(e) => !ignoreClick(e) && router.push(href, as)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultOnClick = (n, router) => () => {
|
||||
const defaultOnClick = n => {
|
||||
if (!n.item.title) {
|
||||
const path = n.item.path.split('.')
|
||||
if (path.length > COMMENT_DEPTH_LIMIT + 1) {
|
||||
const rootId = path.slice(-(COMMENT_DEPTH_LIMIT + 1))[0]
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: rootId, commentId: n.item.id }
|
||||
}, `/items/${rootId}`)
|
||||
return {
|
||||
href: {
|
||||
pathname: '/items/[id]',
|
||||
query: { id: rootId, commentId: n.item.id }
|
||||
},
|
||||
as: `/items/${rootId}`
|
||||
}
|
||||
} else {
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: n.item.root.id, commentId: n.item.id }
|
||||
}, `/items/${n.item.root.id}`)
|
||||
return {
|
||||
href: {
|
||||
pathname: '/items/[id]',
|
||||
query: { id: n.item.root.id, commentId: n.item.id }
|
||||
},
|
||||
as: `/items/${n.item.root.id}`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
router.push({
|
||||
pathname: '/items/[id]',
|
||||
query: { id: n.item.id }
|
||||
}, `/items/${n.item.id}`)
|
||||
return {
|
||||
href: {
|
||||
pathname: '/items/[id]',
|
||||
query: { id: n.item.id }
|
||||
},
|
||||
as: `/items/${n.item.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,33 +122,30 @@ function Streak ({ n }) {
|
|||
|
||||
function EarnNotification ({ n }) {
|
||||
return (
|
||||
<NotificationLayout>
|
||||
<div className='d-flex'>
|
||||
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
||||
<div className='ml-2'>
|
||||
<div className='font-weight-bold text-boost'>
|
||||
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</div>
|
||||
{n.sources &&
|
||||
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
||||
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
|
||||
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
|
||||
{n.sources.tipPosts > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tipPosts} sats for zapping top posts early</span>}
|
||||
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{n.sources.tipComments} sats for zapping top comments early</span>}
|
||||
</div>}
|
||||
<div className='pb-1' style={{ lineHeight: '140%' }}>
|
||||
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs' passHref><a>jobs</a></Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards' passHref><a>here</a></Link>.
|
||||
</div>
|
||||
<div className='d-flex ml-2 py-1'>
|
||||
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
|
||||
<div className='ml-2'>
|
||||
<div className='font-weight-bold text-boost'>
|
||||
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</div>
|
||||
{n.sources &&
|
||||
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
|
||||
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
|
||||
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
|
||||
{n.sources.tipPosts > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tipPosts} sats for zapping top posts early</span>}
|
||||
{n.sources.tipComments > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0 || n.sources.tipPosts > 0) && ' \\ '}{n.sources.tipComments} sats for zapping top comments early</span>}
|
||||
</div>}
|
||||
<div className='pb-1' style={{ lineHeight: '140%' }}>
|
||||
SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards'>here</Link>.
|
||||
</div>
|
||||
</div>
|
||||
</NotificationLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Invitification ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={() => router.push('/invites')}>
|
||||
<NotificationLayout href='/invites'>
|
||||
<small className='font-weight-bold text-secondary ml-2'>
|
||||
your invite has been redeemed by {n.invite.invitees.length} stackers
|
||||
</small>
|
||||
|
@ -157,9 +162,8 @@ function Invitification ({ n }) {
|
|||
}
|
||||
|
||||
function InvoicePaid ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={() => router.push(`/invoices/${n.invoice.id}`)}>
|
||||
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
|
||||
<div className='font-weight-bold text-info ml-2 py-1'>
|
||||
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
|
||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
|
@ -172,7 +176,7 @@ function Referral ({ n }) {
|
|||
return (
|
||||
<NotificationLayout>
|
||||
<small className='font-weight-bold text-secondary ml-2'>
|
||||
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
|
||||
someone joined via one of your <Link href='/referrals/month' className='text-reset'>referral links</Link>
|
||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
</NotificationLayout>
|
||||
|
@ -180,9 +184,8 @@ function Referral ({ n }) {
|
|||
}
|
||||
|
||||
function Votification ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
||||
<NotificationLayout {...defaultOnClick(n)}>
|
||||
<small className='font-weight-bold text-success ml-2'>
|
||||
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
||||
</small>
|
||||
|
@ -202,9 +205,8 @@ function Votification ({ n }) {
|
|||
}
|
||||
|
||||
function Mention ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
||||
<NotificationLayout {...defaultOnClick(n)}>
|
||||
<small className='font-weight-bold text-info ml-2'>
|
||||
you were mentioned in
|
||||
</small>
|
||||
|
@ -223,9 +225,8 @@ function Mention ({ n }) {
|
|||
}
|
||||
|
||||
function JobChanged ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
||||
<NotificationLayout {...defaultOnClick(n)}>
|
||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||
{n.item.status === 'ACTIVE'
|
||||
? 'your job is active again'
|
||||
|
@ -239,9 +240,8 @@ function JobChanged ({ n }) {
|
|||
}
|
||||
|
||||
function Reply ({ n }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<NotificationLayout onClick={defaultOnClick(n, router)} rootText='replying on:'>
|
||||
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
|
||||
<div className='py-2'>
|
||||
{n.item.title
|
||||
? <Item item={n.item} />
|
||||
|
@ -274,8 +274,6 @@ function NotificationAlert () {
|
|||
}
|
||||
}, [sw])
|
||||
|
||||
if (!supported) return null
|
||||
|
||||
const close = () => {
|
||||
localStorage.setItem('hideNotifyPrompt', 'yep')
|
||||
setShowAlert(false)
|
||||
|
@ -305,7 +303,7 @@ function NotificationAlert () {
|
|||
</Alert>
|
||||
)
|
||||
: (
|
||||
<Form className='d-flex justify-content-end' initial={{ pushNotify: hasSubscription }}>
|
||||
<Form className={`d-flex justify-content-end ${supported ? 'visible' : 'invisible'}`} initial={{ pushNotify: hasSubscription }}>
|
||||
<Checkbox
|
||||
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
|
||||
groupClassName={`${styles.subFormGroup} mb-1 mr-sm-3 mr-0`}
|
||||
|
@ -318,34 +316,48 @@ function NotificationAlert () {
|
|||
)
|
||||
}
|
||||
|
||||
export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) {
|
||||
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
|
||||
export default function Notifications ({ ssrData }) {
|
||||
const { data, fetchMore } = useQuery(NOTIFICATIONS)
|
||||
const client = useApolloClient()
|
||||
|
||||
if (data) {
|
||||
({ notifications: { notifications, earn, cursor } } = data)
|
||||
}
|
||||
useEffect(() => {
|
||||
client.writeQuery({
|
||||
query: HAS_NOTIFICATIONS,
|
||||
data: {
|
||||
hasNewNotes: false
|
||||
}
|
||||
})
|
||||
}, [client])
|
||||
|
||||
const [fresh, old] =
|
||||
notifications.reduce((result, n) => {
|
||||
const { notifications: { notifications, earn, lastChecked, cursor } } = useMemo(() => {
|
||||
if (!data && !ssrData) return { notifications: {} }
|
||||
return data || ssrData
|
||||
}, [data, ssrData])
|
||||
|
||||
const [fresh, old] = useMemo(() => {
|
||||
if (!notifications) return [[], []]
|
||||
return notifications.reduce((result, n) => {
|
||||
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
|
||||
return result
|
||||
},
|
||||
[[], []])
|
||||
}, [notifications, lastChecked])
|
||||
|
||||
if (!data && !ssrData) return <CommentsFlatSkeleton />
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationAlert />
|
||||
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
|
||||
<div className='fresh'>
|
||||
{earn && <Notification n={earn} key='earn' />}
|
||||
{fresh.map((n, i) => (
|
||||
<Notification n={n} key={i} />
|
||||
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
|
||||
))}
|
||||
</div>
|
||||
{old.map((n, i) => (
|
||||
<Notification n={n} key={i} />
|
||||
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
|
||||
))}
|
||||
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
|
||||
<MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -354,9 +366,10 @@ function CommentsFlatSkeleton () {
|
|||
const comments = new Array(21).fill(null)
|
||||
|
||||
return (
|
||||
<div>{comments.map((_, i) => (
|
||||
<CommentSkeleton key={i} skeletonChildren={0} />
|
||||
))}
|
||||
<div>
|
||||
{comments.map((_, i) => (
|
||||
<CommentSkeleton key={i} skeletonChildren={0} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import Moon from '../svgs/moon-fill.svg'
|
||||
|
||||
export default function PageLoading () {
|
||||
return (
|
||||
<div className='d-flex justify-content-center mt-3 mb-1'>
|
||||
<Moon className='spin fill-grey' />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,44 +1,26 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import AccordianItem from './accordian-item'
|
||||
import Item, { ItemSkeleton } from './item'
|
||||
import { BOUNTY_ITEMS_BY_USER_NAME } from '../fragments/items'
|
||||
import Link from 'next/link'
|
||||
import styles from './items.module.css'
|
||||
import Items from './items'
|
||||
import { NavigateFooter } from './more-footer'
|
||||
|
||||
export default function PastBounties ({ children, item }) {
|
||||
const emptyItems = new Array(5).fill(null)
|
||||
const LIMIT = 5
|
||||
|
||||
const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, {
|
||||
variables: {
|
||||
name: item.user.name,
|
||||
limit: 5
|
||||
},
|
||||
fetchPolicy: 'cache-first'
|
||||
})
|
||||
|
||||
let items, cursor
|
||||
if (data) {
|
||||
({ getBountiesByUserName: { items, cursor } } = data)
|
||||
items = items.filter(i => i.id !== item.id)
|
||||
export default function PastBounties ({ item }) {
|
||||
const variables = {
|
||||
name: item.user.name,
|
||||
sort: 'user',
|
||||
type: 'bounties',
|
||||
limit: LIMIT
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordianItem
|
||||
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
|
||||
body={
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{loading
|
||||
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
|
||||
: (items?.length
|
||||
? items.map(bountyItem => {
|
||||
return <Item key={bountyItem.id} item={bountyItem} />
|
||||
})
|
||||
: <div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>EMPTY</div>
|
||||
)}
|
||||
</div>
|
||||
{cursor && <Link href={`/${item.user.name}/bounties`} query={{ parent: item }} passHref><a className='text-reset text-muted font-weight-bold'>view all past bounties</a></Link>}
|
||||
</>
|
||||
<Items
|
||||
variables={variables}
|
||||
Footer={props => <NavigateFooter {...props} href={`/${item.user.name}/bounties`} text='view all past bounties' />}
|
||||
filter={i => i.id !== item.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -2,7 +2,6 @@ import React from 'react'
|
|||
import { Button } from 'react-bootstrap'
|
||||
import styles from './pay-bounty.module.css'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import ModalButton from './modal-button'
|
||||
import { useMutation, gql } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
import { abbrNum } from '../lib/format'
|
||||
|
@ -61,7 +60,7 @@ export default function PayBounty ({ children, item }) {
|
|||
}
|
||||
)
|
||||
|
||||
const handlePayBounty = async () => {
|
||||
const handlePayBounty = async onComplete => {
|
||||
try {
|
||||
await act({
|
||||
variables: { id: item.id, sats: root.bounty },
|
||||
|
@ -72,6 +71,7 @@ export default function PayBounty ({ children, item }) {
|
|||
}
|
||||
}
|
||||
})
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
showModal(onClose => {
|
||||
|
@ -92,22 +92,24 @@ export default function PayBounty ({ children, item }) {
|
|||
notForm
|
||||
overlayText={`${root.bounty} sats`}
|
||||
>
|
||||
<ModalButton
|
||||
clicker={
|
||||
<div className={styles.pay}>
|
||||
pay bounty
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
className={styles.pay} onClick={() => {
|
||||
showModal(onClose => (
|
||||
<>
|
||||
<div className='text-center font-weight-bold text-muted'>
|
||||
Pay this bounty to {item.user.name}?
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
|
||||
pay <small>{abbrNum(root.bounty)} sats</small>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
))
|
||||
}}
|
||||
>
|
||||
<div className='text-center font-weight-bold text-muted'>
|
||||
Pay this bounty to {item.user.name}?
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
|
||||
pay <small>{abbrNum(root.bounty)} sats</small>
|
||||
</Button>
|
||||
</div>
|
||||
</ModalButton>
|
||||
pay bounty
|
||||
</div>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
|
|||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||
import { MAX_POLL_NUM_CHOICES } from '../lib/constants'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import FeeButton, { EditFeeButton } from './fee-button'
|
||||
import Delete from './delete'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
@ -74,7 +73,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
topLevel
|
||||
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={2}
|
||||
/>
|
||||
<VariableInput
|
||||
|
|
|
@ -17,7 +17,11 @@ export function usePrice () {
|
|||
export function PriceProvider ({ price, children }) {
|
||||
const me = useMe()
|
||||
const fiatCurrency = me?.fiatCurrency
|
||||
const { data } = useQuery(PRICE, { variables: { fiatCurrency }, pollInterval: 30000, fetchPolicy: 'cache-and-network' })
|
||||
const { data } = useQuery(PRICE, {
|
||||
variables: { fiatCurrency },
|
||||
pollInterval: 30000,
|
||||
nextFetchPolicy: 'cache-and-network'
|
||||
})
|
||||
|
||||
const contextValue = {
|
||||
price: data?.price || price,
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import { ITEM_TYPES } from '../lib/constants'
|
||||
import { Form, Select } from './form'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export default function RecentHeader ({ type, sub }) {
|
||||
const router = useRouter()
|
||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
const prefix = sub ? `/~${sub}` : ''
|
||||
|
||||
const items = ['posts', 'bounties', 'comments', 'links', 'discussions', 'polls']
|
||||
if (!sub?.name) {
|
||||
items.push('bios')
|
||||
}
|
||||
const items = ITEM_TYPES(sub)
|
||||
|
||||
return (
|
||||
<Form
|
||||
|
|
|
@ -1,38 +1,24 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import Link from 'next/link'
|
||||
import { RELATED_ITEMS } from '../fragments/items'
|
||||
import AccordianItem from './accordian-item'
|
||||
import Item, { ItemSkeleton } from './item'
|
||||
import styles from './items.module.css'
|
||||
import Items from './items'
|
||||
import { NavigateFooter } from './more-footer'
|
||||
|
||||
const LIMIT = 5
|
||||
|
||||
export default function Related ({ title, itemId }) {
|
||||
const emptyItems = new Array(5).fill(null)
|
||||
const { data, loading } = useQuery(RELATED_ITEMS, {
|
||||
fetchPolicy: 'cache-first',
|
||||
variables: { title, id: itemId, limit: 5 }
|
||||
})
|
||||
|
||||
let items, cursor
|
||||
if (data) {
|
||||
({ related: { items, cursor } } = data)
|
||||
}
|
||||
const variables = { title, id: itemId, limit: LIMIT }
|
||||
|
||||
return (
|
||||
<AccordianItem
|
||||
header={<div className='font-weight-bold'>related</div>}
|
||||
body={
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{loading
|
||||
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
|
||||
: (items?.length
|
||||
? items.map(item => <Item key={item.id} item={item} />)
|
||||
: <div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>EMPTY</div>
|
||||
)}
|
||||
</div>
|
||||
{cursor && itemId && <Link href={`/items/${itemId}/related`} passHref><a className='text-reset text-muted font-weight-bold'>view all related</a></Link>}
|
||||
</>
|
||||
}
|
||||
<Items
|
||||
query={RELATED_ITEMS}
|
||||
variables={variables}
|
||||
destructureData={data => data.related}
|
||||
Footer={props => <NavigateFooter {...props} href={`/items/${itemId}/related`} text='view all related items' />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { gql, useMutation } from '@apollo/client'
|
|||
import styles from './reply.module.css'
|
||||
import { COMMENTS } from '../fragments/comments'
|
||||
import { useMe } from './me'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import FeeButton from './fee-button'
|
||||
|
@ -13,8 +12,8 @@ import Info from './info'
|
|||
|
||||
export function ReplyOnAnotherPage ({ parentId }) {
|
||||
return (
|
||||
<Link href={`/items/${parentId}`}>
|
||||
<a className={`${styles.replyButtons} text-muted`}>reply on another page</a>
|
||||
<Link href={`/items/${parentId}`} className={`${styles.replyButtons} text-muted`}>
|
||||
reply on another page
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -110,41 +109,41 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||
{/* HACK if we need more items, we should probably do a comment toolbar */}
|
||||
{children}
|
||||
</div>)}
|
||||
<div className={reply ? `${styles.reply}` : 'd-none'}>
|
||||
<Form
|
||||
initial={{
|
||||
text: ''
|
||||
}}
|
||||
schema={commentSchema}
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
const { error } = await createComment({ variables: { ...values, parentId } })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
resetForm({ text: '' })
|
||||
setReply(replyOpen || false)
|
||||
}}
|
||||
storageKeyPrefix={'reply-' + parentId}
|
||||
>
|
||||
<MarkdownInput
|
||||
name='text'
|
||||
as={TextareaAutosize}
|
||||
minRows={6}
|
||||
autoFocus={!replyOpen}
|
||||
required
|
||||
placeholder={placeholder}
|
||||
hint={me?.sats < 1 && <FreebieDialog />}
|
||||
innerRef={replyInput}
|
||||
/>
|
||||
{reply &&
|
||||
<div className='mt-1'>
|
||||
<FeeButton
|
||||
baseFee={1} parentId={parentId} text='reply'
|
||||
ChildButton={SubmitButton} variant='secondary' alwaysShow
|
||||
/>
|
||||
</div>}
|
||||
</Form>
|
||||
</div>
|
||||
{reply &&
|
||||
<div className={styles.reply}>
|
||||
<Form
|
||||
initial={{
|
||||
text: ''
|
||||
}}
|
||||
schema={commentSchema}
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
const { error } = await createComment({ variables: { ...values, parentId } })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
resetForm({ text: '' })
|
||||
setReply(replyOpen || false)
|
||||
}}
|
||||
storageKeyPrefix={'reply-' + parentId}
|
||||
>
|
||||
<MarkdownInput
|
||||
name='text'
|
||||
minRows={6}
|
||||
autoFocus={!replyOpen}
|
||||
required
|
||||
placeholder={placeholder}
|
||||
hint={me?.sats < 1 && <FreebieDialog />}
|
||||
innerRef={replyInput}
|
||||
/>
|
||||
{reply &&
|
||||
<div className='mt-1'>
|
||||
<FeeButton
|
||||
baseFee={1} parentId={parentId} text='reply'
|
||||
ChildButton={SubmitButton} variant='secondary' alwaysShow
|
||||
/>
|
||||
</div>}
|
||||
</Form>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { useQuery } from '@apollo/client'
|
||||
import { ItemSkeleton } from './item'
|
||||
import styles from './items.module.css'
|
||||
import { ITEM_SEARCH } from '../fragments/items'
|
||||
import MoreFooter from './more-footer'
|
||||
import { Fragment } from 'react'
|
||||
import { CommentFlat } from './comment'
|
||||
import ItemFull from './item-full'
|
||||
|
||||
export default function SearchItems ({ variables, items, pins, cursor }) {
|
||||
const { data, fetchMore } = useQuery(ITEM_SEARCH, { variables })
|
||||
|
||||
if (!data && !items) {
|
||||
return <ItemsSkeleton />
|
||||
}
|
||||
|
||||
if (data) {
|
||||
({ search: { items, cursor } } = data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={item.id}>
|
||||
{item.parentId
|
||||
? <><div /><CommentFlat item={item} noReply includeParent /></>
|
||||
: <><div /><div className={item.text ? 'pb-3' : ''}><ItemFull item={item} noReply /></div></>}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<MoreFooter
|
||||
cursor={cursor} fetchMore={fetchMore}
|
||||
noMoreText='EMPTY'
|
||||
Skeleton={() => <ItemsSkeleton />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemsSkeleton () {
|
||||
const items = new Array(21).fill(null)
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{items.map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -34,6 +34,9 @@ export default function Search ({ sub }) {
|
|||
await router.push({
|
||||
pathname: '/stackers/search',
|
||||
query: { q, what: 'stackers' }
|
||||
}, {
|
||||
pathname: '/stackers/search',
|
||||
query: { q }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -50,6 +53,7 @@ export default function Search ({ sub }) {
|
|||
|
||||
const showSearch = atBottom || searching || router.query.q
|
||||
const filter = sub !== 'jobs'
|
||||
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
|
||||
|
@ -60,7 +64,7 @@ export default function Search ({ sub }) {
|
|||
className={styles.formActive}
|
||||
initial={{
|
||||
q: router.query.q || '',
|
||||
what: router.query.what || '',
|
||||
what: what || '',
|
||||
sort: router.query.sort || '',
|
||||
when: router.query.when || ''
|
||||
}}
|
||||
|
@ -75,7 +79,7 @@ export default function Search ({ sub }) {
|
|||
size='sm'
|
||||
items={['all', 'posts', 'comments', 'stackers']}
|
||||
/>
|
||||
{router.query.what !== 'stackers' &&
|
||||
{what !== 'stackers' &&
|
||||
<>
|
||||
by
|
||||
<Select
|
||||
|
|
|
@ -68,7 +68,7 @@ export default function Seo ({ sub, item, user }) {
|
|||
}
|
||||
}
|
||||
if (user) {
|
||||
desc = `@${user.name} has [${user.stacked} stacked, ${user.nitems} posts, ${user.ncomments} comments]`
|
||||
desc = `@${user.name} has [${user.stacked} stacked, ${user.nposts} posts, ${user.ncomments} comments]`
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -2,8 +2,9 @@ import { Alert } from 'react-bootstrap'
|
|||
import YouTube from '../svgs/youtube-line.svg'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
import { dayPivot } from '../lib/time'
|
||||
|
||||
export default function Snl () {
|
||||
export default function Snl ({ ignorePreference }) {
|
||||
const [show, setShow] = useState()
|
||||
const { data } = useQuery(gql`{ snl }`, {
|
||||
fetchPolicy: 'cache-and-network'
|
||||
|
@ -11,14 +12,12 @@ export default function Snl () {
|
|||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem('snl')
|
||||
if (dismissed && dismissed > new Date(dismissed) < new Date(new Date().setDate(new Date().getDate() - 6))) {
|
||||
if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < dayPivot(new Date(), -6)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.snl) {
|
||||
setShow(true)
|
||||
}
|
||||
}, [data])
|
||||
setShow(data?.snl)
|
||||
}, [data, ignorePreference])
|
||||
|
||||
if (!show) return null
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'apollo-server-micro'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
|
||||
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
|
||||
|
|
|
@ -40,7 +40,7 @@ export default function Toc ({ text }) {
|
|||
style={{
|
||||
marginLeft: `${(v.depth - 1) * 5}px`
|
||||
}}
|
||||
key={v.slug} href={`#${v.slug}`}
|
||||
href={`#${v.slug}`} key={v.slug}
|
||||
>{v.heading}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
|
|
|
@ -8,7 +8,7 @@ import sub from '../lib/remark-sub'
|
|||
import remarkDirective from 'remark-directive'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import React, { useRef, useEffect, useState } from 'react'
|
||||
import React, { useRef, useEffect, useState, memo } from 'react'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import LinkIcon from '../svgs/link.svg'
|
||||
import Thumb from '../svgs/thumb-up-fill.svg'
|
||||
|
@ -16,6 +16,7 @@ import { toString } from 'mdast-util-to-string'
|
|||
import copy from 'clipboard-copy'
|
||||
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
|
||||
import { extractUrls } from '../lib/md'
|
||||
import FileMissing from '../svgs/file-warning-line.svg'
|
||||
|
||||
function myRemarkPlugin () {
|
||||
return (tree) => {
|
||||
|
@ -41,7 +42,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
|
|||
const Icon = copied ? Thumb : LinkIcon
|
||||
|
||||
return (
|
||||
<div className={styles.heading}>
|
||||
<span className={styles.heading}>
|
||||
{React.createElement(h, { id, ...props }, children)}
|
||||
{!noFragments && topLevel &&
|
||||
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
||||
|
@ -58,7 +59,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
|
|||
className='fill-grey'
|
||||
/>
|
||||
</a>}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -68,7 +69,8 @@ const CACHE_STATES = {
|
|||
IS_ERROR: 'IS_ERROR'
|
||||
}
|
||||
|
||||
export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
|
||||
// this is one of the slowest components to render
|
||||
export default memo(function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
|
||||
// all the reactStringReplace calls are to facilitate search highlighting
|
||||
const slugger = new GithubSlugger()
|
||||
onlyImgProxy = onlyImgProxy ?? true
|
||||
|
@ -121,9 +123,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
|
|||
h5: (props) => HeadingWrapper({ h: topLevel ? 'h5' : 'h6', ...props }),
|
||||
h6: (props) => HeadingWrapper({ h: 'h6', ...props }),
|
||||
table: ({ node, ...props }) =>
|
||||
<div className='table-responsive'>
|
||||
<span className='table-responsive'>
|
||||
<table className='table table-bordered table-sm' {...props} />
|
||||
</div>,
|
||||
</span>,
|
||||
p: ({ children, ...props }) => <div className={styles.p} {...props}>{children}</div>,
|
||||
code ({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline
|
||||
|
@ -179,13 +182,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
|
|||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function ZoomableImage ({ src, topLevel, ...props }) {
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [err, setErr] = useState()
|
||||
const defaultMediaStyle = {
|
||||
maxHeight: topLevel ? '75vh' : '25vh',
|
||||
cursor: 'zoom-in'
|
||||
|
@ -195,17 +195,17 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
|
|||
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
|
||||
useEffect(() => {
|
||||
setMediaStyle(defaultMediaStyle)
|
||||
setErr(null)
|
||||
}, [src])
|
||||
|
||||
const handleClick = () => {
|
||||
if (mediaStyle.cursor === 'zoom-in') {
|
||||
setMediaStyle({
|
||||
width: '100%',
|
||||
cursor: 'zoom-out'
|
||||
})
|
||||
} else {
|
||||
setMediaStyle(defaultMediaStyle)
|
||||
}
|
||||
if (!src) return null
|
||||
if (err) {
|
||||
return (
|
||||
<span className='d-flex align-items-center text-warning font-weight-bold pb-1'>
|
||||
<FileMissing width={18} height={18} className='fill-warning mr-1' />
|
||||
broken image <small>stacker probably used an unreliable host</small>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -213,7 +213,17 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
|
|||
className={topLevel ? styles.topLevel : undefined}
|
||||
style={mediaStyle}
|
||||
src={src}
|
||||
onClick={handleClick}
|
||||
onClick={() => {
|
||||
if (mediaStyle.cursor === 'zoom-in') {
|
||||
setMediaStyle({
|
||||
width: '100%',
|
||||
cursor: 'zoom-out'
|
||||
})
|
||||
} else {
|
||||
setMediaStyle(defaultMediaStyle)
|
||||
}
|
||||
}}
|
||||
onError={() => setErr(true)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
position: relative;
|
||||
margin-left: -22px;
|
||||
padding-left: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.headingLink {
|
||||
|
@ -23,6 +24,7 @@
|
|||
left: 0px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.headingLink.copied {
|
||||
|
@ -41,7 +43,7 @@
|
|||
border-top: 1px solid var(--theme-clickToContextColor);
|
||||
}
|
||||
|
||||
.text p {
|
||||
.text .p {
|
||||
margin-bottom: .5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { Form, Select } from './form'
|
||||
|
||||
const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
|
||||
const ITEM_SORTS = ['votes', 'comments', 'sats']
|
||||
import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants'
|
||||
|
||||
export default function TopHeader ({ sub, cat }) {
|
||||
const router = useRouter()
|
||||
|
@ -19,11 +17,11 @@ export default function TopHeader ({ sub, cat }) {
|
|||
|
||||
const prefix = sub ? `/~${sub}` : ''
|
||||
|
||||
if (typeof query.sort !== 'undefined') {
|
||||
if (query.sort === '' ||
|
||||
(what === 'stackers' && !USER_SORTS.includes(query.sort)) ||
|
||||
(what !== 'stackers' && !ITEM_SORTS.includes(query.sort))) {
|
||||
delete query.sort
|
||||
if (typeof query.by !== 'undefined') {
|
||||
if (query.by === '' ||
|
||||
(what === 'stackers' && !USER_SORTS.includes(query.by)) ||
|
||||
(what !== 'stackers' && !ITEM_SORTS.includes(query.by))) {
|
||||
delete query.by
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -39,7 +37,7 @@ export default function TopHeader ({ sub, cat }) {
|
|||
className='mr-auto'
|
||||
initial={{
|
||||
what: cat,
|
||||
sort: router.query.sort || '',
|
||||
by: router.query.by || '',
|
||||
when: router.query.when || ''
|
||||
}}
|
||||
onSubmit={top}
|
||||
|
@ -58,8 +56,8 @@ export default function TopHeader ({ sub, cat }) {
|
|||
by
|
||||
<Select
|
||||
groupClassName='mx-2 mb-0'
|
||||
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
|
||||
name='sort'
|
||||
onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })}
|
||||
name='by'
|
||||
size='sm'
|
||||
items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS}
|
||||
/>
|
||||
|
@ -69,7 +67,7 @@ export default function TopHeader ({ sub, cat }) {
|
|||
onChange={(formik, e) => top({ ...formik?.values, when: e.target.value })}
|
||||
name='when'
|
||||
size='sm'
|
||||
items={['day', 'week', 'month', 'year', 'forever']}
|
||||
items={WHENS}
|
||||
/>
|
||||
</>}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import ActionTooltip from './action-tooltip'
|
|||
import ItemAct from './item-act'
|
||||
import { useMe } from './me'
|
||||
import Rainbow from '../lib/rainbow'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import LongPressable from 'react-longpressable'
|
||||
import { Overlay, Popover } from 'react-bootstrap'
|
||||
import { useShowModal } from './modal'
|
||||
|
@ -78,7 +78,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
}`
|
||||
)
|
||||
|
||||
const setVoteShow = (yes) => {
|
||||
const setVoteShow = useCallback((yes) => {
|
||||
if (!me) return
|
||||
|
||||
// if they haven't seen the walkthrough and they have sats
|
||||
|
@ -90,9 +90,9 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
_setVoteShow(false)
|
||||
setWalkthrough({ variables: { upvotePopover: true } })
|
||||
}
|
||||
}
|
||||
}, [me, voteShow, setWalkthrough])
|
||||
|
||||
const setTipShow = (yes) => {
|
||||
const setTipShow = useCallback((yes) => {
|
||||
if (!me) return
|
||||
|
||||
// if we want to show it, yet we still haven't shown
|
||||
|
@ -105,7 +105,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
_setTipShow(false)
|
||||
setWalkthrough({ variables: { tipPopover: true } })
|
||||
}
|
||||
}
|
||||
}, [me, tipShow, setWalkthrough])
|
||||
|
||||
const [act] = useMutation(
|
||||
gql`
|
||||
|
@ -161,18 +161,20 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
}
|
||||
|
||||
if (pendingSats > 0) {
|
||||
timerRef.current = setTimeout(async (pendingSats) => {
|
||||
timerRef.current = setTimeout(async (sats) => {
|
||||
try {
|
||||
timerRef.current && setPendingSats(0)
|
||||
await act({
|
||||
variables: { id: item.id, sats: pendingSats },
|
||||
variables: { id: item.id, sats },
|
||||
optimisticResponse: {
|
||||
act: {
|
||||
sats: pendingSats
|
||||
sats
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (!timerRef.current) return
|
||||
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
|
@ -181,14 +183,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
}
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
}, 1000, pendingSats)
|
||||
}, 500, pendingSats)
|
||||
}
|
||||
|
||||
return () => {
|
||||
return async () => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [item, pendingSats, act, setPendingSats, showModal])
|
||||
}, [pendingSats, act, item, showModal, setPendingSats])
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
|
||||
|
@ -213,7 +215,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
|
||||
return (
|
||||
<LightningConsumer>
|
||||
{({ strike }) =>
|
||||
{(strike) =>
|
||||
<div ref={ref} className='upvoteParent'>
|
||||
<LongPressable
|
||||
onLongPress={
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { Form, Select } from './form'
|
||||
import { WHENS } from '../lib/constants'
|
||||
|
||||
export function UsageHeader () {
|
||||
const router = useRouter()
|
||||
|
@ -17,7 +18,7 @@ export function UsageHeader () {
|
|||
className='w-auto'
|
||||
name='when'
|
||||
size='sm'
|
||||
items={['day', 'week', 'month', 'year', 'forever']}
|
||||
items={WHENS}
|
||||
onChange={(formik, e) => router.push(`/stackers/${e.target.value}`)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -10,20 +10,38 @@ import { useMe } from './me'
|
|||
import { NAME_MUTATION } from '../fragments/users'
|
||||
import QRCode from 'qrcode.react'
|
||||
import LightningIcon from '../svgs/bolt.svg'
|
||||
import ModalButton from './modal-button'
|
||||
import { encodeLNUrl } from '../lib/lnurl'
|
||||
import Avatar from './avatar'
|
||||
import CowboyHat from './cowboy-hat'
|
||||
import { userSchema } from '../lib/validate'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export default function UserHeader ({ user }) {
|
||||
const [editting, setEditting] = useState(false)
|
||||
const me = useMe()
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const schema = userSchema(client)
|
||||
const [setName] = useMutation(NAME_MUTATION)
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderHeader user={user} />
|
||||
<Nav
|
||||
className={styles.nav}
|
||||
activeKey={!!router.asPath.split('/')[2]}
|
||||
>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name} passHref legacyBehavior>
|
||||
<Nav.Link eventKey={false}>bio</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name + '/all'} passHref legacyBehavior>
|
||||
<Nav.Link eventKey>{user.nitems} items</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderPhoto ({ user, isMe }) {
|
||||
const [setPhoto] = useMutation(
|
||||
gql`
|
||||
mutation setPhoto($photoId: ID!) {
|
||||
|
@ -42,138 +60,139 @@ export default function UserHeader ({ user }) {
|
|||
}
|
||||
)
|
||||
|
||||
const isMe = me?.name === user.name
|
||||
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{isMe ? `${user.sats} sats \\ ` : ''}{user.stacked} stacked</div>
|
||||
|
||||
const lnurlp = encodeLNUrl(new URL(`https://stacker.news/.well-known/lnurlp/${user.name}`))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex mt-2 flex-wrap flex-column flex-sm-row'>
|
||||
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
||||
className={styles.userimg}
|
||||
/>
|
||||
{isMe &&
|
||||
<Avatar onSuccess={async photoId => {
|
||||
const { error } = await setPhoto({ variables: { photoId } })
|
||||
if (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
|
||||
{editting
|
||||
? (
|
||||
<Form
|
||||
schema={schema}
|
||||
initial={{
|
||||
name: user.name
|
||||
}}
|
||||
validateImmediately
|
||||
onSubmit={async ({ name }) => {
|
||||
if (name === user.name) {
|
||||
setEditting(false)
|
||||
return
|
||||
}
|
||||
const { error } = await setName({ variables: { name } })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
const { nodata, ...query } = router.query
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, name }
|
||||
})
|
||||
|
||||
client.writeFragment({
|
||||
id: `User:${user.id}`,
|
||||
fragment: gql`
|
||||
fragment CurUser on User {
|
||||
name
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
name
|
||||
}
|
||||
})
|
||||
|
||||
setEditting(false)
|
||||
}}
|
||||
>
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<Input
|
||||
prepend=<InputGroup.Text>@</InputGroup.Text>
|
||||
name='name'
|
||||
autoFocus
|
||||
groupClassName={styles.usernameForm}
|
||||
showValid
|
||||
/>
|
||||
<SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
: (
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
|
||||
{isMe &&
|
||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||
</div>
|
||||
)}
|
||||
<Satistics user={user} />
|
||||
<ModalButton
|
||||
clicker={
|
||||
<Button className='font-weight-bold ml-0'>
|
||||
<LightningIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className='mr-1'
|
||||
/>{user.name}@stacker.news
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<a className='d-flex m-auto p-3' style={{ background: 'white', width: 'fit-content' }} href={`lightning:${lnurlp}`}>
|
||||
<QRCode className='d-flex m-auto' value={lnurlp} renderAs='svg' size={300} />
|
||||
</a>
|
||||
<div className='text-center font-weight-bold text-muted mt-3'>click or scan</div>
|
||||
</ModalButton>
|
||||
<div className='d-flex flex-column mt-1 ml-0'>
|
||||
<small className='text-muted d-flex-inline'>stacking since: {user.since
|
||||
? <Link href={`/items/${user.since}`} passHref><a className='ml-1'>#{user.since}</a></Link>
|
||||
: <span>never</span>}
|
||||
</small>
|
||||
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Nav
|
||||
className={styles.nav}
|
||||
activeKey={router.asPath.split('?')[0]}
|
||||
>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name} passHref>
|
||||
<Nav.Link>bio</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name + '/posts'} passHref>
|
||||
<Nav.Link>{user.nitems} posts</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name + '/comments'} passHref>
|
||||
<Nav.Link>{user.ncomments} comments</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={'/' + user.name + '/bookmarks'} passHref>
|
||||
<Nav.Link>{user.nbookmarks} bookmarks</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</>
|
||||
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
||||
className={styles.userimg}
|
||||
/>
|
||||
{isMe &&
|
||||
<Avatar onSuccess={async photoId => {
|
||||
const { error } = await setPhoto({ variables: { photoId } })
|
||||
if (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NymEdit ({ user, setEditting }) {
|
||||
const router = useRouter()
|
||||
const [setName] = useMutation(NAME_MUTATION, {
|
||||
update (cache, { data: { setName } }) {
|
||||
cache.modify({
|
||||
id: `User:${user.id}`,
|
||||
fields: {
|
||||
name () {
|
||||
return setName
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const client = useApolloClient()
|
||||
const schema = userSchema(client)
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={schema}
|
||||
initial={{
|
||||
name: user.name
|
||||
}}
|
||||
validateImmediately
|
||||
onSubmit={async ({ name }) => {
|
||||
if (name === user.name) {
|
||||
setEditting(false)
|
||||
return
|
||||
}
|
||||
const { error } = await setName({ variables: { name } })
|
||||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
setEditting(false)
|
||||
// navigate to new name
|
||||
const { nodata, ...query } = router.query
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, name }
|
||||
}, undefined, { shallow: true })
|
||||
}}
|
||||
>
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<Input
|
||||
prepend=<InputGroup.Text>@</InputGroup.Text>
|
||||
name='name'
|
||||
autoFocus
|
||||
groupClassName={styles.usernameForm}
|
||||
showValid
|
||||
/>
|
||||
<SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function NymView ({ user, isMe, setEditting }) {
|
||||
return (
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
|
||||
{isMe &&
|
||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderNym ({ user, isMe }) {
|
||||
const [editting, setEditting] = useState(false)
|
||||
|
||||
return editting
|
||||
? <NymEdit user={user} setEditting={setEditting} />
|
||||
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
|
||||
}
|
||||
|
||||
function HeaderHeader ({ user }) {
|
||||
const me = useMe()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const isMe = me?.name === user.name
|
||||
const Satistics = () => <div className={`mb-2 ml-0 ml-sm-1 ${styles.username} text-success`}>{user.stacked} stacked</div>
|
||||
|
||||
const lnurlp = encodeLNUrl(new URL(`https://stacker.news/.well-known/lnurlp/${user.name}`))
|
||||
return (
|
||||
<div className='d-flex mt-2 flex-wrap flex-column flex-sm-row'>
|
||||
<HeaderPhoto user={user} isMe={isMe} />
|
||||
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
|
||||
<HeaderNym user={user} isMe={isMe} />
|
||||
<Satistics user={user} />
|
||||
<Button
|
||||
className='font-weight-bold ml-0' onClick={() => {
|
||||
showModal(({ onClose }) => (
|
||||
<>
|
||||
<a className='d-flex m-auto p-3' style={{ background: 'white', width: 'fit-content' }} href={`lightning:${lnurlp}`}>
|
||||
<QRCode className='d-flex m-auto' value={lnurlp} renderAs='svg' size={300} />
|
||||
</a>
|
||||
<div className='text-center font-weight-bold text-muted mt-3'>click or scan</div>
|
||||
</>
|
||||
))
|
||||
}}
|
||||
>
|
||||
<LightningIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className='mr-1'
|
||||
/>{user.name}@stacker.news
|
||||
</Button>
|
||||
<div className='d-flex flex-column mt-1 ml-0'>
|
||||
<small className='text-muted d-flex-inline'>stacking since: {user.since
|
||||
? <Link href={`/items/${user.since}`} className='ml-1'>#{user.since}</Link>
|
||||
: <span>never</span>}
|
||||
</small>
|
||||
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,16 +4,21 @@
|
|||
}
|
||||
|
||||
.nav {
|
||||
margin-top: 1rem;
|
||||
justify-content: space-between;
|
||||
margin: 1rem 0;
|
||||
justify-content: start;
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.nav div:first-child a {
|
||||
.nav :global .nav-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.nav div:last-child a {
|
||||
padding-right: 0;
|
||||
.nav :global .nav-item:not(:first-child) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.nav :global .active {
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.userimg {
|
||||
|
|
|
@ -4,18 +4,20 @@ import { abbrNum } from '../lib/format'
|
|||
import CowboyHat from './cowboy-hat'
|
||||
import styles from './item.module.css'
|
||||
import userStyles from './user-header.module.css'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import MoreFooter from './more-footer'
|
||||
|
||||
// all of this nonsense is to show the stat we are sorting by first
|
||||
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
|
||||
const Spent = ({ user }) => (<span>{abbrNum(user.spent)} spent</span>)
|
||||
const Posts = ({ user }) => (
|
||||
<Link href={`/${user.name}/posts`} passHref>
|
||||
<a className='text-reset'>{abbrNum(user.nitems)} posts</a>
|
||||
<Link href={`/${user.name}/posts`} className='text-reset'>
|
||||
{abbrNum(user.nposts)} posts
|
||||
</Link>)
|
||||
const Comments = ({ user }) => (
|
||||
<Link href={`/${user.name}/comments`} passHref>
|
||||
<a className='text-reset'>{abbrNum(user.ncomments)} comments</a>
|
||||
<Link href={`/${user.name}/comments`} className='text-reset'>
|
||||
{abbrNum(user.ncomments)} comments
|
||||
</Link>)
|
||||
const Referrals = ({ user }) => (<span>{abbrNum(user.referrals)} referrals</span>)
|
||||
const Seperator = () => (<span> \ </span>)
|
||||
|
@ -33,40 +35,52 @@ function seperate (arr, seperator) {
|
|||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
|
||||
}
|
||||
|
||||
export default function UserList ({ users, sort }) {
|
||||
export default function UserList ({ ssrData, query, variables, destructureData }) {
|
||||
const { data, fetchMore } = useQuery(query, { variables })
|
||||
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
||||
|
||||
useEffect(() => {
|
||||
if (sort) {
|
||||
if (variables.by) {
|
||||
// shift the stat we are sorting by to the front
|
||||
const comps = [...STAT_COMPONENTS]
|
||||
setStatComps(seperate([...comps.splice(STAT_POS[sort], 1), ...comps], Seperator))
|
||||
setStatComps(seperate([...comps.splice(STAT_POS[variables.by], 1), ...comps], Seperator))
|
||||
}
|
||||
}, [sort])
|
||||
}, [variables.by])
|
||||
|
||||
const { users, cursor } = useMemo(() => {
|
||||
if (!data && !ssrData) return {}
|
||||
if (destructureData) {
|
||||
return destructureData(data || ssrData)
|
||||
} else {
|
||||
return data || ssrData
|
||||
}
|
||||
}, [data, ssrData])
|
||||
|
||||
if (!ssrData && !data) {
|
||||
return <UsersSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<> {users.map(user => (
|
||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||
<Link href={`/${user.name}`} passHref>
|
||||
<a>
|
||||
<>
|
||||
{users?.map(user => (
|
||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||
<Link href={`/${user.name}`}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||
className={`${userStyles.userimg} mr-2`}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className={styles.hunk}>
|
||||
<Link href={`/${user.name}`} passHref>
|
||||
<a className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||
@{user.name}<CowboyHat className='ml-1 fill-grey' height={14} width={14} user={user} />
|
||||
</a>
|
||||
</Link>
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
<div className={styles.hunk}>
|
||||
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||
@{user.name}<CowboyHat className='ml-1 fill-grey' height={14} width={14} user={user} />
|
||||
</Link>
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -78,7 +92,8 @@ export function UsersSkeleton () {
|
|||
<div>{users.map((_, i) => (
|
||||
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
|
||||
<Image
|
||||
src='/clouds.jpeg' width='32' height='32'
|
||||
src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/clouds.jpeg`}
|
||||
width='32' height='32'
|
||||
className={`${userStyles.userimg} clouds mr-2`}
|
||||
/>
|
||||
<div className={styles.hunk}>
|
||||
|
|
|
@ -31,57 +31,23 @@ export const COMMENT_FIELDS = gql`
|
|||
}
|
||||
`
|
||||
|
||||
export const MORE_FLAT_COMMENTS = gql`
|
||||
${COMMENT_FIELDS}
|
||||
|
||||
query MoreFlatComments($sub: String, $sort: String!, $cursor: String, $name: String, $within: String) {
|
||||
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentFields
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
export const COMMENTS_ITEM_EXT_FIELDS = gql`
|
||||
fragment CommentItemExtFields on Item {
|
||||
text
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const TOP_COMMENTS = gql`
|
||||
${COMMENT_FIELDS}
|
||||
|
||||
query topComments($sort: String, $cursor: String, $when: String = "day") {
|
||||
topComments(sort: $sort, cursor: $cursor, when: $when) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentFields
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}`
|
||||
|
||||
export const COMMENTS = gql`
|
||||
${COMMENT_FIELDS}
|
||||
|
|
|
@ -91,72 +91,6 @@ export const ITEM_OTS = gql`
|
|||
}
|
||||
}`
|
||||
|
||||
export const ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query items($sub: String, $sort: String, $type: String, $cursor: String, $name: String, $within: String) {
|
||||
items(sub: $sub, sort: $sort, type: $type, cursor: $cursor, name: $name, within: $within) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
},
|
||||
pins {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const TOP_ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query topItems($sort: String, $cursor: String, $when: String) {
|
||||
topItems(sort: $sort, cursor: $cursor, when: $when) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
},
|
||||
pins {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const OUTLAWED_ITEMS = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
|
||||
query outlawedItems($cursor: String) {
|
||||
outlawedItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const BORDERLAND_ITEMS = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
|
||||
query borderlandItems($cursor: String) {
|
||||
borderlandItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const FREEBIE_ITEMS = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
|
||||
query freebieItems($cursor: String) {
|
||||
freebieItems(cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const POLL_FIELDS = gql`
|
||||
fragment PollFields on Item {
|
||||
poll {
|
||||
|
@ -217,31 +151,6 @@ export const ITEM_WITH_COMMENTS = gql`
|
|||
}
|
||||
}`
|
||||
|
||||
export const BOUNTY_ITEMS_BY_USER_NAME = gql`
|
||||
${ITEM_FIELDS}
|
||||
query getBountiesByUserName($name: String!, $cursor: String, $limit: Int) {
|
||||
getBountiesByUserName(name: $name, cursor: $cursor, limit: $limit) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const ITEM_SEARCH = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) {
|
||||
search(q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
searchTitle
|
||||
searchText
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const RELATED_ITEMS = gql`
|
||||
${ITEM_FIELDS}
|
||||
query Related($title: String, $id: ID, $cursor: String, $limit: Int) {
|
||||
|
|
|
@ -2,6 +2,8 @@ import { gql } from '@apollo/client'
|
|||
import { ITEM_FULL_FIELDS } from './items'
|
||||
import { INVITE_FIELDS } from './invites'
|
||||
|
||||
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
|
||||
|
||||
export const NOTIFICATIONS = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
${INVITE_FIELDS}
|
||||
|
@ -13,6 +15,7 @@ export const NOTIFICATIONS = gql`
|
|||
notifications {
|
||||
__typename
|
||||
... on Mention {
|
||||
id
|
||||
sortTime
|
||||
mention
|
||||
item {
|
||||
|
@ -21,6 +24,7 @@ export const NOTIFICATIONS = gql`
|
|||
}
|
||||
}
|
||||
... on Votification {
|
||||
id
|
||||
sortTime
|
||||
earnedSats
|
||||
item {
|
||||
|
@ -34,6 +38,7 @@ export const NOTIFICATIONS = gql`
|
|||
days
|
||||
}
|
||||
... on Earn {
|
||||
id
|
||||
sortTime
|
||||
earnedSats
|
||||
sources {
|
||||
|
@ -44,9 +49,11 @@ export const NOTIFICATIONS = gql`
|
|||
}
|
||||
}
|
||||
... on Referral {
|
||||
id
|
||||
sortTime
|
||||
}
|
||||
... on Reply {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFullFields
|
||||
|
@ -54,18 +61,21 @@ export const NOTIFICATIONS = gql`
|
|||
}
|
||||
}
|
||||
... on Invitification {
|
||||
id
|
||||
sortTime
|
||||
invite {
|
||||
...InviteFields
|
||||
}
|
||||
}
|
||||
... on JobChanged {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
... on InvoicePaid {
|
||||
id
|
||||
sortTime
|
||||
earnedSats
|
||||
invoice {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { ITEM_FIELDS } from './items'
|
||||
import { COMMENT_FIELDS } from './comments'
|
||||
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
|
||||
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
|
||||
|
||||
export const SUB_FIELDS = gql`
|
||||
fragment SubFields on Sub {
|
||||
|
@ -13,7 +13,7 @@ export const SUB_FIELDS = gql`
|
|||
export const SUB = gql`
|
||||
${SUB_FIELDS}
|
||||
|
||||
query Sub($sub: String!) {
|
||||
query Sub($sub: String) {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
|
@ -22,119 +22,43 @@ export const SUB = gql`
|
|||
export const SUB_ITEMS = gql`
|
||||
${SUB_FIELDS}
|
||||
${ITEM_FIELDS}
|
||||
query SubItems($sub: String!, $sort: String, $type: String) {
|
||||
${COMMENTS_ITEM_EXT_FIELDS}
|
||||
|
||||
query SubItems($sub: String, $sort: String, $cursor: String, $type: String, $name: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
items(sub: $sub, sort: $sort, type: $type) {
|
||||
|
||||
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
...CommentItemExtFields @include(if: $includeComments)
|
||||
position
|
||||
},
|
||||
pins {
|
||||
...ItemFields
|
||||
...CommentItemExtFields @include(if: $includeComments)
|
||||
position
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SUB_TOP_ITEMS = gql`
|
||||
${SUB_FIELDS}
|
||||
${ITEM_FIELDS}
|
||||
|
||||
query SubTopItems($sub: String!, $sort: String, $cursor: String, $when: String) {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
topItems(sub: $sub, sort: $sort, cursor: $cursor, when: $when) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
},
|
||||
pins {
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const SUB_TOP_COMMENTS = gql`
|
||||
${SUB_FIELDS}
|
||||
${COMMENT_FIELDS}
|
||||
|
||||
query SubTopComments($sub: String!, $sort: String, $cursor: String, $when: String = "day") {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
topComments(sub: $sub, sort: $sort, cursor: $cursor, when: $when) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentFields
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SUB_SEARCH = gql`
|
||||
${SUB_FIELDS}
|
||||
${ITEM_FIELDS}
|
||||
query SubSearch($sub: String!, $q: String, $cursor: String) {
|
||||
${ITEM_FULL_FIELDS}
|
||||
query SubSearch($sub: String, $q: String, $cursor: String, $sort: String, $what: String, $when: String) {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
search(q: $q, cursor: $cursor) {
|
||||
search(sub: $sub, q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
text
|
||||
...ItemFullFields
|
||||
searchTitle
|
||||
searchText
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SUB_FLAT_COMMENTS = gql`
|
||||
${SUB_FIELDS}
|
||||
${COMMENT_FIELDS}
|
||||
|
||||
query SubFlatComments($sub: String!, $sort: String!, $cursor: String) {
|
||||
sub(name: $sub) {
|
||||
...SubFields
|
||||
}
|
||||
|
||||
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentFields
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { COMMENT_FIELDS } from './comments'
|
||||
import { ITEM_FIELDS, ITEM_FULL_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
|
||||
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||
|
||||
export const ME = gql`
|
||||
{
|
||||
|
@ -116,35 +116,27 @@ gql`
|
|||
stacked
|
||||
spent
|
||||
ncomments
|
||||
nitems
|
||||
nposts
|
||||
referrals
|
||||
}
|
||||
}`
|
||||
|
||||
export const USER_FIELDS = gql`
|
||||
${ITEM_FIELDS}
|
||||
fragment UserFields on User {
|
||||
id
|
||||
createdAt
|
||||
name
|
||||
streak
|
||||
maxStreak
|
||||
hideCowboyHat
|
||||
nitems
|
||||
ncomments
|
||||
nbookmarks
|
||||
stacked
|
||||
sats
|
||||
since
|
||||
photoId
|
||||
bio {
|
||||
...ItemFields
|
||||
text
|
||||
}
|
||||
}`
|
||||
|
||||
export const TOP_USERS = gql`
|
||||
query TopUsers($cursor: String, $when: String, $sort: String) {
|
||||
topUsers(cursor: $cursor, when: $when, sort: $sort) {
|
||||
query TopUsers($cursor: String, $when: String, $by: String) {
|
||||
topUsers(cursor: $cursor, when: $when, by: $by) {
|
||||
users {
|
||||
name
|
||||
streak
|
||||
|
@ -153,7 +145,7 @@ export const TOP_USERS = gql`
|
|||
stacked(when: $when)
|
||||
spent(when: $when)
|
||||
ncomments(when: $when)
|
||||
nitems(when: $when)
|
||||
nposts(when: $when)
|
||||
referrals(when: $when)
|
||||
}
|
||||
cursor
|
||||
|
@ -172,7 +164,7 @@ export const TOP_COWBOYS = gql`
|
|||
stacked(when: "forever")
|
||||
spent(when: "forever")
|
||||
ncomments(when: "forever")
|
||||
nitems(when: "forever")
|
||||
nposts(when: "forever")
|
||||
referrals(when: "forever")
|
||||
}
|
||||
cursor
|
||||
|
@ -186,74 +178,25 @@ export const USER_FULL = gql`
|
|||
query User($name: String!) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
since
|
||||
bio {
|
||||
...ItemWithComments
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const USER_WITH_COMMENTS = gql`
|
||||
${USER_FIELDS}
|
||||
${COMMENT_FIELDS}
|
||||
query UserWithComments($name: String!) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
since
|
||||
}
|
||||
moreFlatComments(sort: "user", name: $name) {
|
||||
cursor
|
||||
comments {
|
||||
...CommentFields
|
||||
root {
|
||||
id
|
||||
title
|
||||
bounty
|
||||
bountyPaidTo
|
||||
subName
|
||||
user {
|
||||
name
|
||||
streak
|
||||
hideCowboyHat
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const USER_WITH_BOOKMARKS = gql`
|
||||
${USER_FIELDS}
|
||||
${ITEM_FULL_FIELDS}
|
||||
query UserWithBookmarks($name: String!, $cursor: String) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
since
|
||||
}
|
||||
moreBookmarks(name: $name, cursor: $cursor) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFullFields
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const USER_WITH_POSTS = gql`
|
||||
export const USER_WITH_ITEMS = gql`
|
||||
${USER_FIELDS}
|
||||
${ITEM_FIELDS}
|
||||
query UserWithPosts($name: String!) {
|
||||
${COMMENTS_ITEM_EXT_FIELDS}
|
||||
query UserWithItems($name: String!, $sub: String, $cursor: String, $type: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
|
||||
user(name: $name) {
|
||||
...UserFields
|
||||
since
|
||||
}
|
||||
items(sort: "user", name: $name) {
|
||||
items(sub: $sub, sort: "user", cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
|
||||
cursor
|
||||
items {
|
||||
...ItemFields
|
||||
}
|
||||
pins {
|
||||
...ItemFields
|
||||
...CommentItemExtFields @include(if: $includeComments)
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
|
|
@ -1,461 +0,0 @@
|
|||
import {
|
||||
$applyNodeReplacement,
|
||||
$getNodeByKey,
|
||||
$getSelection,
|
||||
$isNodeSelection,
|
||||
$setSelection,
|
||||
CLICK_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createEditor, DecoratorNode,
|
||||
DRAGSTART_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND
|
||||
} from 'lexical'
|
||||
import { useRef, Suspense, useEffect, useCallback } from 'react'
|
||||
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
|
||||
const imageCache = new Set()
|
||||
|
||||
function useSuspenseImage (src) {
|
||||
if (!imageCache.has(src)) {
|
||||
throw new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.src = src
|
||||
img.onload = () => {
|
||||
imageCache.add(src)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function LazyImage ({
|
||||
altText,
|
||||
className,
|
||||
imageRef,
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
maxWidth
|
||||
}) {
|
||||
useSuspenseImage(src)
|
||||
return (
|
||||
<img
|
||||
className={className || undefined}
|
||||
src={src}
|
||||
alt={altText}
|
||||
ref={imageRef}
|
||||
style={{
|
||||
height,
|
||||
maxHeight: '25vh',
|
||||
// maxWidth,
|
||||
// width,
|
||||
display: 'block',
|
||||
marginBottom: '.5rem',
|
||||
marginTop: '.5rem',
|
||||
borderRadius: '.4rem',
|
||||
width: 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function convertImageElement (domNode) {
|
||||
if (domNode instanceof HTMLImageElement) {
|
||||
const { alt: altText, src } = domNode
|
||||
const node = $createImageNode({ altText, src })
|
||||
return { node }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class ImageNode extends DecoratorNode {
|
||||
__src;
|
||||
__altText;
|
||||
__width;
|
||||
__height;
|
||||
__maxWidth;
|
||||
__showCaption;
|
||||
__caption;
|
||||
// Captions cannot yet be used within editor cells
|
||||
__captionsEnabled;
|
||||
|
||||
static getType () {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
static clone (node) {
|
||||
return new ImageNode(
|
||||
node.__src,
|
||||
node.__altText,
|
||||
node.__maxWidth,
|
||||
node.__width,
|
||||
node.__height,
|
||||
node.__showCaption,
|
||||
node.__caption,
|
||||
node.__captionsEnabled,
|
||||
node.__key
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON (serializedNode) {
|
||||
const { altText, height, width, maxWidth, caption, src, showCaption } =
|
||||
serializedNode
|
||||
const node = $createImageNode({
|
||||
altText,
|
||||
height,
|
||||
maxWidth,
|
||||
showCaption,
|
||||
src,
|
||||
width
|
||||
})
|
||||
const nestedEditor = node.__caption
|
||||
const editorState = nestedEditor.parseEditorState(caption.editorState)
|
||||
if (!editorState.isEmpty()) {
|
||||
nestedEditor.setEditorState(editorState)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
exportDOM () {
|
||||
const element = document.createElement('img')
|
||||
element.setAttribute('src', this.__src)
|
||||
element.setAttribute('alt', this.__altText)
|
||||
return { element }
|
||||
}
|
||||
|
||||
static importDOM () {
|
||||
return {
|
||||
img: (node) => ({
|
||||
conversion: convertImageElement,
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
constructor (
|
||||
src,
|
||||
altText,
|
||||
maxWidth,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled,
|
||||
key
|
||||
) {
|
||||
super(key)
|
||||
this.__src = src
|
||||
this.__altText = altText
|
||||
this.__maxWidth = maxWidth
|
||||
this.__width = width || 'inherit'
|
||||
this.__height = height || 'inherit'
|
||||
this.__showCaption = showCaption || false
|
||||
this.__caption = caption || createEditor()
|
||||
this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined
|
||||
}
|
||||
|
||||
exportJSON () {
|
||||
return {
|
||||
altText: this.getAltText(),
|
||||
caption: this.__caption.toJSON(),
|
||||
height: this.__height === 'inherit' ? 0 : this.__height,
|
||||
maxWidth: this.__maxWidth,
|
||||
showCaption: this.__showCaption,
|
||||
src: this.getSrc(),
|
||||
type: 'image',
|
||||
version: 1,
|
||||
width: this.__width === 'inherit' ? 0 : this.__width
|
||||
}
|
||||
}
|
||||
|
||||
setWidthAndHeight (
|
||||
width,
|
||||
height
|
||||
) {
|
||||
const writable = this.getWritable()
|
||||
writable.__width = width
|
||||
writable.__height = height
|
||||
}
|
||||
|
||||
setShowCaption (showCaption) {
|
||||
const writable = this.getWritable()
|
||||
writable.__showCaption = showCaption
|
||||
}
|
||||
|
||||
// View
|
||||
|
||||
createDOM (config) {
|
||||
const span = document.createElement('span')
|
||||
const theme = config.theme
|
||||
const className = theme.image
|
||||
if (className !== undefined) {
|
||||
span.className = className
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
updateDOM () {
|
||||
return false
|
||||
}
|
||||
|
||||
getSrc () {
|
||||
return this.__src
|
||||
}
|
||||
|
||||
getAltText () {
|
||||
return this.__altText
|
||||
}
|
||||
|
||||
decorate () {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ImageComponent
|
||||
src={this.__src}
|
||||
altText={this.__altText}
|
||||
width={this.__width}
|
||||
height={this.__height}
|
||||
maxWidth={this.__maxWidth}
|
||||
nodeKey={this.getKey()}
|
||||
showCaption={this.__showCaption}
|
||||
caption={this.__caption}
|
||||
captionsEnabled={this.__captionsEnabled}
|
||||
resizable
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function $createImageNode ({
|
||||
altText,
|
||||
height,
|
||||
maxWidth = 500,
|
||||
captionsEnabled,
|
||||
src,
|
||||
width,
|
||||
showCaption,
|
||||
caption,
|
||||
key
|
||||
}) {
|
||||
return $applyNodeReplacement(
|
||||
new ImageNode(
|
||||
src,
|
||||
altText,
|
||||
maxWidth,
|
||||
width,
|
||||
height,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled,
|
||||
key
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function $isImageNode (
|
||||
node
|
||||
) {
|
||||
return node instanceof ImageNode
|
||||
}
|
||||
|
||||
export default function ImageComponent ({
|
||||
src,
|
||||
altText,
|
||||
nodeKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
resizable,
|
||||
showCaption,
|
||||
caption,
|
||||
captionsEnabled
|
||||
}) {
|
||||
const imageRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
const [isSelected, setSelected, clearSelection] =
|
||||
useLexicalNodeSelection(nodeKey)
|
||||
const [editor] = useLexicalComposerContext()
|
||||
// const [selection, setSelection] = useState(null)
|
||||
const activeEditorRef = useRef(null)
|
||||
|
||||
const onDelete = useCallback(
|
||||
(payload) => {
|
||||
if (isSelected && $isNodeSelection($getSelection())) {
|
||||
const event = payload
|
||||
event.preventDefault()
|
||||
const node = $getNodeByKey(nodeKey)
|
||||
if ($isImageNode(node)) {
|
||||
node.remove()
|
||||
}
|
||||
setSelected(false)
|
||||
}
|
||||
return false
|
||||
},
|
||||
[isSelected, nodeKey, setSelected]
|
||||
)
|
||||
|
||||
const onEnter = useCallback(
|
||||
(event) => {
|
||||
const latestSelection = $getSelection()
|
||||
const buttonElem = buttonRef.current
|
||||
if (
|
||||
isSelected &&
|
||||
$isNodeSelection(latestSelection) &&
|
||||
latestSelection.getNodes().length === 1
|
||||
) {
|
||||
if (showCaption) {
|
||||
// Move focus into nested editor
|
||||
$setSelection(null)
|
||||
event.preventDefault()
|
||||
caption.focus()
|
||||
return true
|
||||
} else if (
|
||||
buttonElem !== null &&
|
||||
buttonElem !== document.activeElement
|
||||
) {
|
||||
event.preventDefault()
|
||||
buttonElem.focus()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
[caption, isSelected, showCaption]
|
||||
)
|
||||
|
||||
const onEscape = useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
activeEditorRef.current === caption ||
|
||||
buttonRef.current === event.target
|
||||
) {
|
||||
$setSelection(null)
|
||||
editor.update(() => {
|
||||
setSelected(true)
|
||||
const parentRootElement = editor.getRootElement()
|
||||
if (parentRootElement !== null) {
|
||||
parentRootElement.focus()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[caption, editor, setSelected]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
// editor.registerUpdateListener(({ editorState }) => {
|
||||
// setSelection(editorState.read(() => $getSelection()))
|
||||
// }),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_, activeEditor) => {
|
||||
activeEditorRef.current = activeEditor
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
CLICK_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected)
|
||||
} else {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (event.target === imageRef.current) {
|
||||
if (event.shiftKey) {
|
||||
setSelected(!isSelected)
|
||||
} else {
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
if (event.target === imageRef.current) {
|
||||
// TODO This is just a temporary workaround for FF to behave like other browsers.
|
||||
// Ideally, this handles drag & drop too (and all browsers).
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_DELETE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_BACKSPACE_COMMAND,
|
||||
onDelete,
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_LOW),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_LOW
|
||||
)
|
||||
)
|
||||
}, [
|
||||
clearSelection,
|
||||
editor,
|
||||
isSelected,
|
||||
nodeKey,
|
||||
onDelete,
|
||||
onEnter,
|
||||
onEscape,
|
||||
setSelected
|
||||
])
|
||||
|
||||
// const draggable = isSelected && $isNodeSelection(selection)
|
||||
// const isFocused = isSelected
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<>
|
||||
<div draggable>
|
||||
<LazyImage
|
||||
// className={
|
||||
// isFocused
|
||||
// ? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
|
||||
// : null
|
||||
// }
|
||||
src={src}
|
||||
altText={altText}
|
||||
imageRef={imageRef}
|
||||
width={width}
|
||||
height={height}
|
||||
maxWidth={maxWidth}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
|
||||
|
||||
const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
|
||||
|
||||
const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
|
||||
|
||||
const MATCHERS = [
|
||||
(text) => {
|
||||
const match = URL_MATCHER.exec(text)
|
||||
return (
|
||||
match && {
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
text: match[0],
|
||||
url: match[0]
|
||||
}
|
||||
)
|
||||
},
|
||||
(text) => {
|
||||
const match = EMAIL_MATCHER.exec(text)
|
||||
return (
|
||||
match && {
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
text: match[0],
|
||||
url: `mailto:${match[0]}`
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
export default function PlaygroundAutoLinkPlugin () {
|
||||
return <AutoLinkPlugin matchers={MATCHERS} />
|
||||
}
|
|
@ -1,248 +0,0 @@
|
|||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRootOrShadowRoot,
|
||||
$setSelection,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
createCommand,
|
||||
DRAGOVER_COMMAND,
|
||||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND
|
||||
} from 'lexical'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ensureProtocol } from '../../lib/url'
|
||||
|
||||
import {
|
||||
$createImageNode,
|
||||
$isImageNode,
|
||||
ImageNode
|
||||
} from '../nodes/image'
|
||||
import { Form, Input, SubmitButton } from '../../components/form'
|
||||
import styles from '../styles.module.css'
|
||||
import { urlSchema } from '../../lib/validate'
|
||||
|
||||
const getDOMSelection = (targetWindow) =>
|
||||
typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null
|
||||
|
||||
export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND')
|
||||
|
||||
export function ImageInsertModal ({ onClose, editor }) {
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
url: '',
|
||||
alt: ''
|
||||
}}
|
||||
schema={urlSchema}
|
||||
onSubmit={async ({ alt, url }) => {
|
||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, { src: ensureProtocol(url), altText: alt })
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
innerRef={inputRef}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={<>alt text <small className='text-muted ml-2'>optional</small></>}
|
||||
name='alt'
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImageInsertPlugin ({
|
||||
captionsEnabled
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([ImageNode])) {
|
||||
throw new Error('ImagesPlugin: ImageNode not registered on editor')
|
||||
}
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_IMAGE_COMMAND,
|
||||
(payload) => {
|
||||
const imageNode = $createImageNode(payload)
|
||||
$insertNodes([imageNode])
|
||||
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGSTART_COMMAND,
|
||||
(event) => {
|
||||
return onDragStart(event)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
),
|
||||
editor.registerCommand(
|
||||
DRAGOVER_COMMAND,
|
||||
(event) => {
|
||||
return onDragover(event)
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
DROP_COMMAND,
|
||||
(event) => {
|
||||
return onDrop(event, editor)
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
)
|
||||
)
|
||||
}, [captionsEnabled, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const TRANSPARENT_IMAGE =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||
const img = typeof window !== 'undefined' ? document.createElement('img') : undefined
|
||||
if (img) {
|
||||
img.src = TRANSPARENT_IMAGE
|
||||
}
|
||||
|
||||
function onDragStart (event) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) {
|
||||
return false
|
||||
}
|
||||
dataTransfer.setData('text/plain', '_')
|
||||
img.src = node.getSrc()
|
||||
dataTransfer.setDragImage(img, 0, 0)
|
||||
dataTransfer.setData(
|
||||
'application/x-lexical-drag',
|
||||
JSON.stringify({
|
||||
data: {
|
||||
altText: node.__altText,
|
||||
caption: node.__caption,
|
||||
height: node.__height,
|
||||
maxHeight: '25vh',
|
||||
key: node.getKey(),
|
||||
maxWidth: node.__maxWidth,
|
||||
showCaption: node.__showCaption,
|
||||
src: node.__src,
|
||||
width: node.__width
|
||||
},
|
||||
type: 'image'
|
||||
})
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function onDragover (event) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (!canDropImage(event)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onDrop (event, editor) {
|
||||
const node = getImageNodeInSelection()
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
const data = getDragImageData(event)
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
if (canDropImage(event)) {
|
||||
const range = getDragSelection(event)
|
||||
node.remove()
|
||||
const rangeSelection = $createRangeSelection()
|
||||
if (range !== null && range !== undefined) {
|
||||
rangeSelection.applyDOMRange(range)
|
||||
}
|
||||
$setSelection(rangeSelection)
|
||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function getImageNodeInSelection () {
|
||||
const selection = $getSelection()
|
||||
const nodes = selection.getNodes()
|
||||
const node = nodes[0]
|
||||
return $isImageNode(node) ? node : null
|
||||
}
|
||||
|
||||
function getDragImageData (event) {
|
||||
const dragData = event.dataTransfer?.getData('application/x-lexical-drag')
|
||||
if (!dragData) {
|
||||
return null
|
||||
}
|
||||
const { type, data } = JSON.parse(dragData)
|
||||
if (type !== 'image') {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function canDropImage (event) {
|
||||
const target = event.target
|
||||
return !!(
|
||||
target &&
|
||||
target instanceof HTMLElement &&
|
||||
!target.closest('code, span.editor-image') &&
|
||||
target.parentElement &&
|
||||
target.parentElement.closest(`div.${styles.editorInput}`)
|
||||
)
|
||||
}
|
||||
|
||||
function getDragSelection (event) {
|
||||
let range
|
||||
const target = event.target
|
||||
const targetWindow =
|
||||
target == null
|
||||
? null
|
||||
: target.nodeType === 9
|
||||
? target.defaultView
|
||||
: target.ownerDocument.defaultView
|
||||
const domSelection = getDOMSelection(targetWindow)
|
||||
if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(event.clientX, event.clientY)
|
||||
} else if (event.rangeParent && domSelection !== null) {
|
||||
domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
|
||||
range = domSelection.getRangeAt(0)
|
||||
} else {
|
||||
throw Error('Cannot get the selection when dragging')
|
||||
}
|
||||
|
||||
return range
|
||||
}
|
|
@ -1,129 +0,0 @@
|
|||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $createTextNode, $getSelection, $insertNodes, $setSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
|
||||
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils'
|
||||
import { $createLinkNode, $isLinkNode } from '@lexical/link'
|
||||
import { Modal } from 'react-bootstrap'
|
||||
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
|
||||
import { Form, Input, SubmitButton } from '../../components/form'
|
||||
import { ensureProtocol } from '../../lib/url'
|
||||
import { getSelectedNode } from '../utils/selected-node'
|
||||
import { namedUrlSchema } from '../../lib/validate'
|
||||
|
||||
export const INSERT_LINK_COMMAND = createCommand('INSERT_LINK_COMMAND')
|
||||
|
||||
export default function LinkInsertPlugin () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_LINK_COMMAND,
|
||||
(payload) => {
|
||||
const selection = $getSelection()
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
parent.remove()
|
||||
} else if ($isLinkNode(node)) {
|
||||
node.remove()
|
||||
}
|
||||
const textNode = $createTextNode(payload.text)
|
||||
$insertNodes([textNode])
|
||||
const linkNode = $createLinkNode(payload.url)
|
||||
$wrapNodeInElement(textNode, () => linkNode)
|
||||
$setSelection(textNode.select())
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR
|
||||
)
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const LinkInsertContext = React.createContext({
|
||||
link: null,
|
||||
setLink: () => {}
|
||||
})
|
||||
|
||||
export function LinkInsertProvider ({ children }) {
|
||||
const [link, setLink] = useState(null)
|
||||
|
||||
const contextValue = {
|
||||
link,
|
||||
setLink: useCallback(link => setLink(link), [])
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkInsertContext.Provider value={contextValue}>
|
||||
<LinkInsertModal />
|
||||
{children}
|
||||
</LinkInsertContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLinkInsert () {
|
||||
const { link, setLink } = useContext(LinkInsertContext)
|
||||
return { link, setLink }
|
||||
}
|
||||
|
||||
export function LinkInsertModal () {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { link, setLink } = useLinkInsert()
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (link) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [link])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={!!link}
|
||||
onHide={() => {
|
||||
setLink(null)
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='modal-close' onClick={() => {
|
||||
setLink(null)
|
||||
// I think bootstrap messes with the focus on close so we have to do this ourselves
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>X
|
||||
</div>
|
||||
<Modal.Body>
|
||||
<Form
|
||||
initial={{
|
||||
text: link?.text,
|
||||
url: link?.url
|
||||
}}
|
||||
schema={namedUrlSchema}
|
||||
onSubmit={async ({ text, url }) => {
|
||||
editor.dispatchCommand(INSERT_LINK_COMMAND, { url: ensureProtocol(url), text })
|
||||
await setLink(null)
|
||||
setTimeout(() => editor.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='text'
|
||||
name='text'
|
||||
innerRef={inputRef}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label='url'
|
||||
name='url'
|
||||
required
|
||||
/>
|
||||
<div className='d-flex'>
|
||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4'>ok</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,232 +0,0 @@
|
|||
import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
||||
import styles from '../styles.module.css'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND
|
||||
} from 'lexical'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { getSelectedNode } from '../utils/selected-node'
|
||||
import { setTooltipPosition } from '../utils/tooltip-position'
|
||||
import { useLinkInsert } from './link-insert'
|
||||
import { getLinkFromSelection } from '../utils/link-from-selection'
|
||||
|
||||
function FloatingLinkEditor ({
|
||||
editor,
|
||||
isLink,
|
||||
setIsLink,
|
||||
anchorElem
|
||||
}) {
|
||||
const { setLink } = useLinkInsert()
|
||||
const editorRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [isEditMode, setEditMode] = useState(false)
|
||||
|
||||
const updateLinkEditor = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const parent = node.getParent()
|
||||
if ($isLinkNode(parent)) {
|
||||
setLinkUrl(parent.getURL())
|
||||
} else if ($isLinkNode(node)) {
|
||||
setLinkUrl(node.getURL())
|
||||
} else {
|
||||
setLinkUrl('')
|
||||
}
|
||||
}
|
||||
const editorElem = editorRef.current
|
||||
const nativeSelection = window.getSelection()
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (editorElem === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (
|
||||
selection !== null &&
|
||||
nativeSelection !== null &&
|
||||
rootElement !== null &&
|
||||
rootElement.contains(nativeSelection.anchorNode) &&
|
||||
editor.isEditable()
|
||||
) {
|
||||
const domRange = nativeSelection.getRangeAt(0)
|
||||
let rect
|
||||
if (nativeSelection.anchorNode === rootElement) {
|
||||
let inner = rootElement
|
||||
while (inner.firstElementChild != null) {
|
||||
inner = inner.firstElementChild
|
||||
}
|
||||
rect = inner.getBoundingClientRect()
|
||||
} else {
|
||||
rect = domRange.getBoundingClientRect()
|
||||
}
|
||||
|
||||
setTooltipPosition(rect, editorElem, anchorElem)
|
||||
} else if (!activeElement) {
|
||||
if (rootElement !== null) {
|
||||
setTooltipPosition(null, editorElem, anchorElem)
|
||||
}
|
||||
setEditMode(false)
|
||||
setLinkUrl('')
|
||||
}
|
||||
|
||||
return true
|
||||
}, [anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const update = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.addEventListener('scroll', update)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
|
||||
if (scrollerElem) {
|
||||
scrollerElem.removeEventListener('scroll', update)
|
||||
}
|
||||
}
|
||||
}, [anchorElem.parentElement, editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}),
|
||||
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updateLinkEditor()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
() => {
|
||||
if (isLink) {
|
||||
setIsLink(false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
)
|
||||
)
|
||||
}, [editor, updateLinkEditor, setIsLink, isLink])
|
||||
|
||||
useEffect(() => {
|
||||
editor.getEditorState().read(() => {
|
||||
updateLinkEditor()
|
||||
})
|
||||
}, [editor, updateLinkEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditMode])
|
||||
|
||||
return (
|
||||
linkUrl &&
|
||||
<div ref={editorRef} className={styles.linkTooltip}>
|
||||
<div className='tooltip-inner d-flex'>
|
||||
<a href={linkUrl} target='_blank' rel='noreferrer' className={`${styles.tooltipUrl} text-reset`}>{linkUrl.replace('https://', '').replace('http://', '')}</a>
|
||||
<span className='px-1'> \ </span>
|
||||
<span
|
||||
className='pointer'
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
// we need to replace the link
|
||||
// their playground simple 'TOGGLE's it with a new url
|
||||
// but we need to potentiallyr replace the text
|
||||
setLink(getLinkFromSelection())
|
||||
})
|
||||
}}
|
||||
>edit
|
||||
</span>
|
||||
<span className='px-1'> \ </span>
|
||||
<span
|
||||
className='pointer'
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}}
|
||||
>remove
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useFloatingLinkEditorToolbar ({ editor, anchorElem }) {
|
||||
const [activeEditor, setActiveEditor] = useState(editor)
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
const linkParent = $findMatchingParent(node, $isLinkNode)
|
||||
const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode)
|
||||
|
||||
// We don't want this menu to open for auto links.
|
||||
if (linkParent != null && autoLinkParent == null) {
|
||||
setIsLink(true)
|
||||
} else {
|
||||
setIsLink(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, newEditor) => {
|
||||
updateToolbar()
|
||||
setActiveEditor(newEditor)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL
|
||||
)
|
||||
}, [editor, updateToolbar])
|
||||
|
||||
return isLink
|
||||
? <FloatingLinkEditor
|
||||
editor={activeEditor}
|
||||
isLink={isLink}
|
||||
anchorElem={anchorElem}
|
||||
setIsLink={setIsLink}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
export default function LinkTooltipPlugin ({
|
||||
anchorElem = document.body
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
return useFloatingLinkEditorToolbar({ editor, anchorElem })
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue