shield your eyes; massive, squashed refactor; nextjs/react/react-dom/apollo upgrades
This commit is contained in:
parent
9b57bbcda3
commit
d0314ab73c
1
.npmrc
1
.npmrc
@ -1,3 +1,2 @@
|
|||||||
unsafe-perm=true
|
unsafe-perm=true
|
||||||
|
|
||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
@ -1,11 +1,11 @@
|
|||||||
import { AuthenticationError } from 'apollo-server-micro'
|
import { GraphQLError } from 'graphql'
|
||||||
import { inviteSchema, ssValidate } from '../../lib/validate'
|
import { inviteSchema, ssValidate } from '../../lib/validate'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
invites: async (parent, args, { me, models }) => {
|
invites: async (parent, args, { me, models }) => {
|
||||||
if (!me) {
|
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({
|
return await models.invite.findMany({
|
||||||
@ -29,7 +29,7 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
createInvite: async (parent, { gift, limit }, { me, models }) => {
|
createInvite: async (parent, { gift, limit }, { me, models }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(inviteSchema, { gift, limit })
|
||||||
@ -40,7 +40,7 @@ export default {
|
|||||||
},
|
},
|
||||||
revokeInvite: async (parent, { id }, { me, models }) => {
|
revokeInvite: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
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({
|
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 { ensureProtocol, removeTracking } from '../../lib/url'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
@ -6,7 +6,8 @@ import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
|||||||
import domino from 'domino'
|
import domino from 'domino'
|
||||||
import {
|
import {
|
||||||
BOOST_MIN, ITEM_SPAM_INTERVAL,
|
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'
|
} from '../../lib/constants'
|
||||||
import { msatsToSats } from '../../lib/format'
|
import { msatsToSats } from '../../lib/format'
|
||||||
import { parse } from 'tldts'
|
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 { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
||||||
import { sendUserNotification } from '../webPush'
|
import { sendUserNotification } from '../webPush'
|
||||||
import { proxyImages } from './imgproxy'
|
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) {
|
async function comments (me, models, id, sort) {
|
||||||
let orderBy
|
let orderBy
|
||||||
@ -53,9 +74,9 @@ export async function getItem (parent, { id }, { me, models }) {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
function topClause (within) {
|
function whenClause (when, type) {
|
||||||
let interval = ' AND "Item".created_at >= $1 - INTERVAL '
|
let interval = ` AND "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL `
|
||||||
switch (within) {
|
switch (when) {
|
||||||
case 'forever':
|
case 'forever':
|
||||||
interval = ''
|
interval = ''
|
||||||
break
|
break
|
||||||
@ -75,14 +96,16 @@ function topClause (within) {
|
|||||||
return interval
|
return interval
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topOrderClause (sort, me, models) {
|
const orderByClause = async (by, me, models, type) => {
|
||||||
switch (sort) {
|
switch (by) {
|
||||||
case 'comments':
|
case 'comments':
|
||||||
return 'ORDER BY ncomments DESC'
|
return 'ORDER BY "Item".ncomments DESC'
|
||||||
case 'sats':
|
case 'sats':
|
||||||
return 'ORDER BY msats DESC'
|
return 'ORDER BY "Item".msats DESC'
|
||||||
default:
|
case 'votes':
|
||||||
return await topOrderByWeightedSats(me, models)
|
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'
|
return 'JOIN sat_rank_tender_view ON "Item".id = sat_rank_tender_view.id'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commentFilterClause (me, models) {
|
export async function filterClause (me, models, type) {
|
||||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
// if you are explicitly asking for marginal content, don't filter them
|
||||||
if (me) {
|
if (['outlawed', 'borderland', 'freebies'].includes(type)) {
|
||||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
if (me && ['outlawed', 'borderland'].includes(type)) {
|
||||||
// wild west mode has everything
|
// unless the item is mine
|
||||||
if (user.wildWestMode) {
|
return ` AND "Item"."userId" <> ${me.id} `
|
||||||
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// always include if it's mine
|
|
||||||
clause += ` OR "Item"."userId" = ${me.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// close the clause
|
|
||||||
clause += ')'
|
|
||||||
|
|
||||||
return clause
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function filterClause (me, models) {
|
|
||||||
// by default don't include freebies unless they have upvotes
|
// by default don't include freebies unless they have upvotes
|
||||||
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
|
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
|
||||||
if (me) {
|
if (me) {
|
||||||
@ -162,20 +176,33 @@ export async function filterClause (me, models) {
|
|||||||
return clause
|
return clause
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentClause (type) {
|
function typeClause (type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'links':
|
case 'links':
|
||||||
return ' AND url IS NOT NULL'
|
return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||||
case 'discussions':
|
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':
|
case 'polls':
|
||||||
return ' AND "pollCost" IS NOT NULL'
|
return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||||
case 'bios':
|
case 'bios':
|
||||||
return ' AND bio = true'
|
return ' AND "Item".bio = true AND "Item"."parentId" IS NULL'
|
||||||
case 'bounties':
|
case 'bounties':
|
||||||
return ' AND bounty IS NOT NULL'
|
return ' AND "Item".bounty IS NOT NULL AND "Item"."parentId" IS NULL'
|
||||||
default:
|
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 ''
|
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} ` : ''
|
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 {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
||||||
@ -228,62 +277,9 @@ export default {
|
|||||||
|
|
||||||
return count
|
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 decodedCursor = decodeCursor(cursor)
|
||||||
const subArr = sub ? [sub] : []
|
let items, user, pins, subFull, table
|
||||||
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\' '
|
|
||||||
}
|
|
||||||
|
|
||||||
// HACK we want to optionally include the subName in the query
|
// HACK we want to optionally include the subName in the query
|
||||||
// but the query planner doesn't like unused parameters
|
// but the query planner doesn't like unused parameters
|
||||||
@ -292,29 +288,32 @@ export default {
|
|||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'user':
|
case 'user':
|
||||||
if (!name) {
|
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 } })
|
user = await models.user.findUnique({ where: { name } })
|
||||||
if (!user) {
|
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({
|
items = await itemQueryWithMeta({
|
||||||
me,
|
me,
|
||||||
models,
|
models,
|
||||||
query: `
|
query: `
|
||||||
${SELECT}
|
${SELECT}
|
||||||
FROM "Item"
|
${relationClause(type)}
|
||||||
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
|
WHERE "${table}"."userId" = $2 AND "${table}".created_at <= $1
|
||||||
AND "pinId" IS NULL
|
${subClause(sub, 5, subClauseTable(type))}
|
||||||
${activeOrMine()}
|
${activeOrMine(me)}
|
||||||
${await filterClause(me, models)}
|
${await filterClause(me, models, type)}
|
||||||
ORDER BY created_at DESC
|
${typeClause(type)}
|
||||||
|
${whenClause(when || 'forever', type)}
|
||||||
|
${await orderByClause(by, me, models, type)}
|
||||||
OFFSET $3
|
OFFSET $3
|
||||||
LIMIT ${LIMIT}`,
|
LIMIT $4`,
|
||||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
orderBy: await orderByClause(by, me, models, type)
|
||||||
}, user.id, decodedCursor.time, decodedCursor.offset)
|
}, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr)
|
||||||
break
|
break
|
||||||
case 'recent':
|
case 'recent':
|
||||||
items = await itemQueryWithMeta({
|
items = await itemQueryWithMeta({
|
||||||
@ -322,17 +321,17 @@ export default {
|
|||||||
models,
|
models,
|
||||||
query: `
|
query: `
|
||||||
${SELECT}
|
${SELECT}
|
||||||
FROM "Item"
|
${relationClause(type)}
|
||||||
WHERE "parentId" IS NULL AND created_at <= $1
|
WHERE "Item".created_at <= $1
|
||||||
${subClause(sub, 3)}
|
${subClause(sub, 4, subClauseTable(type))}
|
||||||
${activeOrMine()}
|
${activeOrMine(me)}
|
||||||
${await filterClause(me, models)}
|
${await filterClause(me, models, type)}
|
||||||
${recentClause(type)}
|
${typeClause(type)}
|
||||||
ORDER BY created_at DESC
|
ORDER BY "Item".created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`,
|
LIMIT $3`,
|
||||||
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
orderBy: 'ORDER BY "Item"."createdAt" DESC'
|
||||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||||
break
|
break
|
||||||
case 'top':
|
case 'top':
|
||||||
items = await itemQueryWithMeta({
|
items = await itemQueryWithMeta({
|
||||||
@ -340,16 +339,18 @@ export default {
|
|||||||
models,
|
models,
|
||||||
query: `
|
query: `
|
||||||
${SELECT}
|
${SELECT}
|
||||||
FROM "Item"
|
${relationClause(type)}
|
||||||
WHERE "parentId" IS NULL AND "Item".created_at <= $1
|
WHERE "Item".created_at <= $1
|
||||||
AND "pinId" IS NULL AND "deletedAt" IS NULL
|
AND "Item"."pinId" IS NULL AND "Item"."deletedAt" IS NULL
|
||||||
${topClause(within)}
|
${subClause(sub, 4, subClauseTable(type))}
|
||||||
${await filterClause(me, models)}
|
${typeClause(type)}
|
||||||
${await topOrderByWeightedSats(me, models)}
|
${whenClause(when, type)}
|
||||||
|
${await filterClause(me, models, type)}
|
||||||
|
${await orderByClause(by || 'votes', me, models, type)}
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`,
|
LIMIT $3`,
|
||||||
orderBy: await topOrderByWeightedSats(me, models)
|
orderBy: await orderByClause(by || 'votes', me, models, type)
|
||||||
}, decodedCursor.time, decodedCursor.offset)
|
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
// sub so we know the default ranking
|
// sub so we know the default ranking
|
||||||
@ -372,13 +373,13 @@ export default {
|
|||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE "parentId" IS NULL AND created_at <= $1
|
WHERE "parentId" IS NULL AND created_at <= $1
|
||||||
AND "pinId" IS NULL
|
AND "pinId" IS NULL
|
||||||
${subClause(sub, 3)}
|
${subClause(sub, 4)}
|
||||||
AND status IN ('ACTIVE', 'NOSATS')
|
AND status IN ('ACTIVE', 'NOSATS')
|
||||||
ORDER BY group_rank, rank
|
ORDER BY group_rank, rank
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`,
|
LIMIT $3`,
|
||||||
orderBy: 'ORDER BY group_rank, rank'
|
orderBy: 'ORDER BY group_rank, rank'
|
||||||
}, decodedCursor.time, decodedCursor.offset, ...subArr)
|
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
items = await itemQueryWithMeta({
|
items = await itemQueryWithMeta({
|
||||||
@ -388,12 +389,12 @@ export default {
|
|||||||
${SELECT}, rank
|
${SELECT}, rank
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
${await joinSatRankView(me, models)}
|
${await joinSatRankView(me, models)}
|
||||||
${subClause(sub, 2, 'Item', true)}
|
${subClause(sub, 3, 'Item', true)}
|
||||||
ORDER BY rank ASC
|
ORDER BY rank ASC
|
||||||
OFFSET $1
|
OFFSET $1
|
||||||
LIMIT ${LIMIT}`,
|
LIMIT $2`,
|
||||||
orderBy: 'ORDER BY rank ASC'
|
orderBy: 'ORDER BY rank ASC'
|
||||||
}, decodedCursor.offset, ...subArr)
|
}, decodedCursor.offset, limit, ...subArr)
|
||||||
|
|
||||||
if (decodedCursor.offset === 0) {
|
if (decodedCursor.offset === 0) {
|
||||||
// get pins for the page and return those separately
|
// get pins for the page and return those separately
|
||||||
@ -419,230 +420,11 @@ export default {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
|
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
|
||||||
items,
|
items,
|
||||||
pins
|
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,
|
item: getItem,
|
||||||
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
|
||||||
const res = {}
|
const res = {}
|
||||||
@ -758,7 +540,7 @@ export default {
|
|||||||
deleteItem: async (parent, { id }, { me, models }) => {
|
deleteItem: async (parent, { id }, { me, models }) => {
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
if (Number(old.userId) !== Number(me?.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() }
|
const data = { deletedAt: new Date() }
|
||||||
@ -813,9 +595,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
|
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) {
|
if (!me) {
|
||||||
throw new AuthenticationError('you must be logged in')
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionCount = id
|
const optionCount = id
|
||||||
@ -832,14 +614,14 @@ export default {
|
|||||||
if (forward) {
|
if (forward) {
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||||
if (!fwdUser) {
|
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) {
|
if (id) {
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
if (Number(old.userId) !== Number(me?.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,
|
const [item] = await serialize(models,
|
||||||
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
|
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 }) => {
|
upsertJob: async (parent, { id, ...data }, { me, models }) => {
|
||||||
if (!me) {
|
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 { sub, title, company, location, remote, text, url, maxBid, status, logo } = data
|
||||||
|
|
||||||
const fullSub = await models.sub.findUnique({ where: { name: sub } })
|
const fullSub = await models.sub.findUnique({ where: { name: sub } })
|
||||||
if (!fullSub) {
|
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)
|
await ssValidate(jobSchema, data, models)
|
||||||
@ -876,7 +658,7 @@ export default {
|
|||||||
if (id) {
|
if (id) {
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
if (Number(old.userId) !== Number(me?.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,
|
([item] = await serialize(models,
|
||||||
models.$queryRaw(
|
models.$queryRaw(
|
||||||
@ -919,7 +701,7 @@ export default {
|
|||||||
},
|
},
|
||||||
pollVote: async (parent, { id }, { me, models }) => {
|
pollVote: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new AuthenticationError('you must be logged in')
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
await serialize(models,
|
await serialize(models,
|
||||||
@ -931,7 +713,7 @@ export default {
|
|||||||
act: async (parent, { id, sats }, { me, models }) => {
|
act: async (parent, { id, sats }, { me, models }) => {
|
||||||
// need to make sure we are logged in
|
// need to make sure we are logged in
|
||||||
if (!me) {
|
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 })
|
await ssValidate(amountSchema, { amount: sats })
|
||||||
@ -942,7 +724,7 @@ export default {
|
|||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
||||||
if (item) {
|
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)})`)
|
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 }) => {
|
dontLikeThis: async (parent, { id }, { me, models }) => {
|
||||||
// need to make sure we are logged in
|
// need to make sure we are logged in
|
||||||
if (!me) {
|
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
|
// disallow self down votes
|
||||||
@ -973,7 +755,7 @@ export default {
|
|||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
|
||||||
if (item) {
|
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})`)
|
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'
|
return item.subName === 'jobs'
|
||||||
},
|
},
|
||||||
sub: async (item, args, { models }) => {
|
sub: async (item, args, { models }) => {
|
||||||
if (!item.subName) {
|
if (!item.subName && !item.root) {
|
||||||
return null
|
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 }) => {
|
position: async (item, args, { models }) => {
|
||||||
if (!item.pinId) {
|
if (!item.pinId) {
|
||||||
@ -1070,7 +852,8 @@ export default {
|
|||||||
if (item.comments) {
|
if (item.comments) {
|
||||||
return 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) => {
|
wvotes: async (item) => {
|
||||||
return item.weightedVotes - item.weightedDownVotes
|
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
|
// update iff this item belongs to me
|
||||||
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
const old = await models.item.findUnique({ where: { id: Number(id) } })
|
||||||
if (Number(old.userId) !== Number(me?.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
|
// if it's not the FAQ, not their bio, and older than 10 minutes
|
||||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
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) {
|
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) {
|
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) {
|
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
|
let fwdUser
|
||||||
if (forward) {
|
if (forward) {
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||||
if (!fwdUser) {
|
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 }) => {
|
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
|
||||||
if (!me) {
|
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) {
|
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) {
|
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
|
let fwdUser
|
||||||
if (forward) {
|
if (forward) {
|
||||||
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
fwdUser = await models.user.findUnique({ where: { name: forward } })
|
||||||
if (!fwdUser) {
|
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 { randomBytes } from 'crypto'
|
||||||
import { bech32 } from 'bech32'
|
import { bech32 } from 'bech32'
|
||||||
import { AuthenticationError } from 'apollo-server-micro'
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
function encodedUrl (iurl, tag, k1) {
|
function encodedUrl (iurl, tag, k1) {
|
||||||
const url = new URL(iurl)
|
const url = new URL(iurl)
|
||||||
@ -30,7 +30,7 @@ export default {
|
|||||||
},
|
},
|
||||||
createWith: async (parent, args, { me, models }) => {
|
createWith: async (parent, args, { me, models }) => {
|
||||||
if (!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.lnWith.create({ data: { k1: k1(), userId: me.id } })
|
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 {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
@ -11,7 +11,7 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
createMessage: async (parent, { text }, { me, models }) => {
|
createMessage: async (parent, { text }, { me, models }) => {
|
||||||
if (!text) {
|
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({
|
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 { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import { getItem, filterClause } from './item'
|
import { getItem, filterClause } from './item'
|
||||||
import { getInvoice } from './wallet'
|
import { getInvoice } from './wallet'
|
||||||
@ -10,7 +10,7 @@ export default {
|
|||||||
notifications: async (parent, { cursor, inc }, { me, models }) => {
|
notifications: async (parent, { cursor, inc }, { me, models }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
if (!me) {
|
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 } })
|
const meFull = await models.user.findUnique({ where: { id: me.id } })
|
||||||
@ -228,7 +228,7 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
|
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
|
||||||
@ -250,14 +250,12 @@ export default {
|
|||||||
},
|
},
|
||||||
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
|
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
|
||||||
if (!me) {
|
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) } })
|
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
throw new UserInputError('endpoint not found', {
|
throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
argumentName: 'endpoint'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
await models.pushSubscription.delete({ where: { id: subscription.id } })
|
await models.pushSubscription.delete({ where: { id: subscription.id } })
|
||||||
return subscription
|
return subscription
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { AuthenticationError } from 'apollo-server-micro'
|
import { GraphQLError } from 'graphql'
|
||||||
import { withClause, intervalClause, timeUnit } from './growth'
|
import { withClause, intervalClause, timeUnit } from './growth'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
referrals: async (parent, { when }, { models, me }) => {
|
referrals: async (parent, { when }, { models, me }) => {
|
||||||
if (!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(`
|
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 { amountSchema, ssValidate } from '../../lib/validate'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
donateToRewards: async (parent, { sats }, { me, models }) => {
|
donateToRewards: async (parent, { sats }, { me, models }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(amountSchema, { amount: sats })
|
||||||
|
@ -79,7 +79,7 @@ export default {
|
|||||||
items
|
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)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
let sitems
|
let sitems
|
||||||
|
|
||||||
@ -105,8 +105,7 @@ export default {
|
|||||||
const queryArr = query.trim().split(/\s+/)
|
const queryArr = query.trim().split(/\s+/)
|
||||||
const url = queryArr.find(word => word.startsWith('url:'))
|
const url = queryArr.find(word => word.startsWith('url:'))
|
||||||
const nym = queryArr.find(word => word.startsWith('nym:'))
|
const nym = queryArr.find(word => word.startsWith('nym:'))
|
||||||
const sub = queryArr.find(word => word.startsWith('~'))
|
const exclude = [url, nym]
|
||||||
const exclude = [url, nym, sub]
|
|
||||||
query = queryArr.filter(word => !exclude.includes(word)).join(' ')
|
query = queryArr.filter(word => !exclude.includes(word)).join(' ')
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
@ -118,7 +117,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub) {
|
if (sub) {
|
||||||
whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } })
|
whatArr.push({ match: { 'sub.name': sub } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortArr = []
|
const sortArr = []
|
||||||
@ -247,7 +246,7 @@ export default {
|
|||||||
highlight: {
|
highlight: {
|
||||||
fields: {
|
fields: {
|
||||||
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
|
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 })
|
const item = await getItem(parent, { id: e._source.id }, { me, models })
|
||||||
|
|
||||||
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
|
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
|
return item
|
||||||
})
|
})
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
const { UserInputError } = require('apollo-server-micro')
|
const { GraphQLError } = require('graphql')
|
||||||
const retry = require('async-retry')
|
const retry = require('async-retry')
|
||||||
|
|
||||||
async function serialize (models, call) {
|
async function serialize (models, call) {
|
||||||
@ -12,7 +12,7 @@ async function serialize (models, call) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
|
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')) {
|
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
|
||||||
bail(new Error('wallet balance transaction is not serializable'))
|
bail(new Error('wallet balance transaction is not serializable'))
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
sub: async (parent, { name }, { models, me }) => {
|
sub: async (parent, { name }, { models, me }) => {
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
if (me && name === 'jobs') {
|
if (me && name === 'jobs') {
|
||||||
models.user.update({
|
models.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
|
import { GraphQLError } from 'graphql'
|
||||||
import AWS from 'aws-sdk'
|
import AWS from 'aws-sdk'
|
||||||
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
||||||
|
|
||||||
@ -12,19 +12,19 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
|
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
|
||||||
if (!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) {
|
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) {
|
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) {
|
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
|
// 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 { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import { msatsToSats } from '../../lib/format'
|
import { msatsToSats } from '../../lib/format'
|
||||||
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
|
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
|
||||||
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
|
import { dayPivot } from '../../lib/time'
|
||||||
|
|
||||||
export function within (table, within) {
|
export function within (table, within) {
|
||||||
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
|
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
|
||||||
@ -53,13 +54,13 @@ export function viewWithin (table, within) {
|
|||||||
export function withinDate (within) {
|
export function withinDate (within) {
|
||||||
switch (within) {
|
switch (within) {
|
||||||
case 'day':
|
case 'day':
|
||||||
return new Date(new Date().setDate(new Date().getDate() - 1))
|
return dayPivot(new Date(), -1)
|
||||||
case 'week':
|
case 'week':
|
||||||
return new Date(new Date().setDate(new Date().getDate() - 7))
|
return dayPivot(new Date(), -7)
|
||||||
case 'month':
|
case 'month':
|
||||||
return new Date(new Date().setDate(new Date().getDate() - 30))
|
return dayPivot(new Date(), -30)
|
||||||
case 'year':
|
case 'year':
|
||||||
return new Date(new Date().setDate(new Date().getDate() - 365))
|
return dayPivot(new Date(), -365)
|
||||||
default:
|
default:
|
||||||
return new Date(0)
|
return new Date(0)
|
||||||
}
|
}
|
||||||
@ -97,7 +98,7 @@ export default {
|
|||||||
},
|
},
|
||||||
settings: async (parent, args, { models, me }) => {
|
settings: async (parent, args, { models, me }) => {
|
||||||
if (!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 } })
|
return await models.user.findUnique({ where: { id: me.id } })
|
||||||
@ -109,7 +110,7 @@ export default {
|
|||||||
await models.user.findMany(),
|
await models.user.findMany(),
|
||||||
nameAvailable: async (parent, { name }, { models, me }) => {
|
nameAvailable: async (parent, { name }, { models, me }) => {
|
||||||
if (!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 } })
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||||
@ -120,7 +121,7 @@ export default {
|
|||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
const users = await models.$queryRaw(`
|
const users = await models.$queryRaw(`
|
||||||
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
|
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
|
floor(sum(msats_stacked)/1000) as stacked
|
||||||
FROM users
|
FROM users
|
||||||
LEFT JOIN user_stats_days on users.id = user_stats_days.id
|
LEFT JOIN user_stats_days on users.id = user_stats_days.id
|
||||||
@ -134,15 +135,15 @@ export default {
|
|||||||
users
|
users
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
topUsers: async (parent, { cursor, when, sort }, { models, me }) => {
|
topUsers: async (parent, { cursor, when, by }, { models, me }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
let users
|
let users
|
||||||
|
|
||||||
if (when !== 'day') {
|
if (when !== 'day') {
|
||||||
let column
|
let column
|
||||||
switch (sort) {
|
switch (by) {
|
||||||
case 'spent': column = 'spent'; break
|
case 'spent': column = 'spent'; break
|
||||||
case 'posts': column = 'nitems'; break
|
case 'posts': column = 'nposts'; break
|
||||||
case 'comments': column = 'ncomments'; break
|
case 'comments': column = 'ncomments'; break
|
||||||
case 'referrals': column = 'referrals'; break
|
case 'referrals': column = 'referrals'; break
|
||||||
default: column = 'stacked'; break
|
default: column = 'stacked'; break
|
||||||
@ -151,7 +152,7 @@ export default {
|
|||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
WITH u AS (
|
WITH u AS (
|
||||||
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
|
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
|
floor(sum(msats_stacked)/1000) as stacked
|
||||||
FROM user_stats_days
|
FROM user_stats_days
|
||||||
JOIN users on users.id = user_stats_days.id
|
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(`
|
users = await models.$queryRaw(`
|
||||||
SELECT users.*, sum(sats_spent) as spent
|
SELECT users.*, sum(sats_spent) as spent
|
||||||
FROM
|
FROM
|
||||||
@ -190,19 +191,19 @@ export default {
|
|||||||
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (sort === 'posts') {
|
} else if (by === 'posts') {
|
||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
SELECT users.*, count(*) as nitems
|
SELECT users.*, count(*) as nposts
|
||||||
FROM users
|
FROM users
|
||||||
JOIN "Item" on "Item"."userId" = users.id
|
JOIN "Item" on "Item"."userId" = users.id
|
||||||
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL
|
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL
|
||||||
AND NOT users."hideFromTopUsers"
|
AND NOT users."hideFromTopUsers"
|
||||||
${within('Item', when)}
|
${within('Item', when)}
|
||||||
GROUP BY users.id
|
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
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (sort === 'comments') {
|
} else if (by === 'comments') {
|
||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
SELECT users.*, count(*) as ncomments
|
SELECT users.*, count(*) as ncomments
|
||||||
FROM users
|
FROM users
|
||||||
@ -214,7 +215,7 @@ export default {
|
|||||||
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
|
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else if (sort === 'referrals') {
|
} else if (by === 'referrals') {
|
||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
SELECT users.*, count(*) as referrals
|
SELECT users.*, count(*) as referrals
|
||||||
FROM users
|
FROM users
|
||||||
@ -427,23 +428,24 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
setName: async (parent, data, { me, models }) => {
|
setName: async (parent, data, { me, models }) => {
|
||||||
if (!me) {
|
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)
|
await ssValidate(userSchema, data, models)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await models.user.update({ where: { id: me.id }, data })
|
await models.user.update({ where: { id: me.id }, data })
|
||||||
|
return data.name
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'P2002') {
|
if (error.code === 'P2002') {
|
||||||
throw new UserInputError('name taken')
|
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
|
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(settingsSchema, { nostrRelays, ...data })
|
||||||
@ -469,7 +471,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
||||||
if (!me) {
|
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 } })
|
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
|
||||||
@ -478,7 +480,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setPhoto: async (parent, { photoId }, { me, models }) => {
|
setPhoto: async (parent, { photoId }, { me, models }) => {
|
||||||
if (!me) {
|
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({
|
await models.user.update({
|
||||||
@ -490,7 +492,7 @@ export default {
|
|||||||
},
|
},
|
||||||
upsertBio: async (parent, { bio }, { me, models }) => {
|
upsertBio: async (parent, { bio }, { me, models }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(bioSchema, { bio })
|
||||||
@ -510,7 +512,7 @@ export default {
|
|||||||
},
|
},
|
||||||
unlinkAuth: async (parent, { authType }, { models, me }) => {
|
unlinkAuth: async (parent, { authType }, { models, me }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new AuthenticationError('you must be logged in')
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let user
|
let user
|
||||||
@ -518,7 +520,7 @@ export default {
|
|||||||
user = await models.user.findUnique({ where: { id: me.id } })
|
user = await models.user.findUnique({ where: { id: me.id } })
|
||||||
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
|
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
|
||||||
if (!account) {
|
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 } })
|
await models.account.delete({ where: { id: account.id } })
|
||||||
} else if (authType === 'lightning') {
|
} else if (authType === 'lightning') {
|
||||||
@ -528,14 +530,14 @@ export default {
|
|||||||
} else if (authType === 'email') {
|
} else if (authType === 'email') {
|
||||||
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
|
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
|
||||||
} else {
|
} else {
|
||||||
throw new UserInputError('no such account')
|
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return await authMethods(user, undefined, { models, me })
|
return await authMethods(user, undefined, { models, me })
|
||||||
},
|
},
|
||||||
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
|
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
|
||||||
if (!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 })
|
await ssValidate(emailSchema, { email })
|
||||||
@ -547,7 +549,7 @@ export default {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'P2002') {
|
if (error.code === 'P2002') {
|
||||||
throw new UserInputError('email taken')
|
throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@ -581,6 +583,20 @@ export default {
|
|||||||
return user.nitems
|
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({
|
return await models.item.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
|
||||||
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
|
import { GraphQLError } from 'graphql'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import lnpr from 'bolt11'
|
import lnpr from 'bolt11'
|
||||||
@ -10,7 +10,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l
|
|||||||
|
|
||||||
export async function getInvoice (parent, { id }, { me, models }) {
|
export async function getInvoice (parent, { id }, { me, models }) {
|
||||||
if (!me) {
|
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({
|
const inv = await models.invoice.findUnique({
|
||||||
@ -23,7 +23,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (inv.user.id !== me.id) {
|
if (inv.user.id !== me.id) {
|
||||||
throw new AuthenticationError('not ur invoice')
|
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return inv
|
return inv
|
||||||
@ -34,7 +34,7 @@ export default {
|
|||||||
invoice: getInvoice,
|
invoice: getInvoice,
|
||||||
withdrawl: async (parent, { id }, { me, models, lnd }) => {
|
withdrawl: async (parent, { id }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
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({
|
const wdrwl = await models.withdrawl.findUnique({
|
||||||
@ -47,7 +47,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (wdrwl.user.id !== me.id) {
|
if (wdrwl.user.id !== me.id) {
|
||||||
throw new AuthenticationError('not ur withdrawal')
|
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return wdrwl
|
return wdrwl
|
||||||
@ -58,7 +58,7 @@ export default {
|
|||||||
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
|
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
if (!me) {
|
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(','))
|
const include = new Set(inc?.split(','))
|
||||||
@ -191,7 +191,7 @@ export default {
|
|||||||
Mutation: {
|
Mutation: {
|
||||||
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
|
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
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 })
|
await ssValidate(amountSchema, { amount })
|
||||||
@ -239,9 +239,7 @@ export default {
|
|||||||
const milliamount = amount * 1000
|
const milliamount = amount * 1000
|
||||||
// check that amount is within min and max sendable
|
// check that amount is within min and max sendable
|
||||||
if (milliamount < res1.minSendable || milliamount > res1.maxSendable) {
|
if (milliamount < res1.minSendable || milliamount > res1.maxSendable) {
|
||||||
throw new UserInputError(
|
throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } })
|
||||||
`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`,
|
|
||||||
{ argumentName: 'amount' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const callback = new URL(res1.callback)
|
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 })
|
decoded = await decodePaymentRequest({ lnd, request: invoice })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(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) {
|
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
|
const msatsFee = Number(maxFee) * 1000
|
||||||
|
@ -31,16 +31,38 @@ export default async function getSSRApolloClient (req, me = null) {
|
|||||||
slashtags
|
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
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
|
export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notFoundFunc, requireVar) {
|
||||||
return async function ({ req, query: params }) {
|
return async function ({ req, query: params }) {
|
||||||
const { nodata, ...realParams } = 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 vars = { ...realParams, ...variables }
|
||||||
|
const query = typeof queryOrFunc === 'function' ? queryOrFunc(vars) : queryOrFunc
|
||||||
|
|
||||||
const client = await getSSRApolloClient(req)
|
const client = await getSSRApolloClient(req)
|
||||||
|
|
||||||
const { data: { me } } = await client.query({
|
const { data: { me } } = await client.query({
|
||||||
@ -52,20 +74,6 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||||||
query: PRICE, variables: { fiatCurrency: me?.fiatCurrency }
|
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]) {
|
if (requireVar && !vars[requireVar]) {
|
||||||
return {
|
return {
|
||||||
notFound: true
|
notFound: true
|
||||||
@ -91,7 +99,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data || (notFoundFunc && notFoundFunc(data))) {
|
if (error || !data || (notFoundFunc && notFoundFunc(data, vars))) {
|
||||||
return {
|
return {
|
||||||
notFound: true
|
notFound: true
|
||||||
}
|
}
|
||||||
@ -110,7 +118,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||||||
...props,
|
...props,
|
||||||
me,
|
me,
|
||||||
price,
|
price,
|
||||||
data
|
ssrData: data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
type NameValue {
|
type NameValue {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
import user from './user'
|
import user from './user'
|
||||||
import message from './message'
|
import message from './message'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,25 +1,16 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): Items
|
||||||
moreFlatComments(sub: String, sort: String!, cursor: String, name: String, within: String): Comments
|
|
||||||
moreBookmarks(cursor: String, name: String!): Items
|
|
||||||
item(id: ID!): Item
|
item(id: ID!): Item
|
||||||
comments(id: ID!, sort: String): [Item!]!
|
comments(id: ID!, sort: String): [Item!]!
|
||||||
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
pageTitleAndUnshorted(url: String!): TitleUnshorted
|
||||||
dupes(url: String!): [Item!]
|
dupes(url: String!): [Item!]
|
||||||
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
|
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
|
||||||
allItems(cursor: String): Items
|
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
|
||||||
getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items
|
|
||||||
search(q: String, cursor: String, what: String, sort: String, when: String): Items
|
|
||||||
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
auctionPosition(sub: String, id: ID, bid: Int!): Int!
|
||||||
itemRepetition(parentId: ID): 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 {
|
type TitleUnshorted {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
@ -11,33 +11,39 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Votification {
|
type Votification {
|
||||||
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
item: Item!
|
item: Item!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Reply {
|
type Reply {
|
||||||
|
id: ID!
|
||||||
item: Item!
|
item: Item!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mention {
|
type Mention {
|
||||||
|
id: ID!
|
||||||
mention: Boolean!
|
mention: Boolean!
|
||||||
item: Item!
|
item: Item!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invitification {
|
type Invitification {
|
||||||
|
id: ID!
|
||||||
invite: Invite!
|
invite: Invite!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobChanged {
|
type JobChanged {
|
||||||
|
id: ID!
|
||||||
item: Item!
|
item: Item!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type EarnSources {
|
type EarnSources {
|
||||||
|
id: ID!
|
||||||
posts: Int!
|
posts: Int!
|
||||||
comments: Int!
|
comments: Int!
|
||||||
tipPosts: Int!
|
tipPosts: Int!
|
||||||
@ -45,24 +51,27 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Streak {
|
type Streak {
|
||||||
|
id: ID!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
days: Int
|
days: Int
|
||||||
id: ID!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Earn {
|
type Earn {
|
||||||
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
sources: EarnSources
|
sources: EarnSources
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvoicePaid {
|
type InvoicePaid {
|
||||||
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
invoice: Invoice!
|
invoice: Invoice!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Referral {
|
type Referral {
|
||||||
|
id: ID!
|
||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
sub(name: String!): Sub
|
sub(name: String): Sub
|
||||||
subLatestPost(name: String!): String
|
subLatestPost(name: String!): String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
scalar JSONObject
|
scalar JSONObject
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
@ -7,7 +7,7 @@ export default gql`
|
|||||||
user(name: String!): User
|
user(name: String!): User
|
||||||
users: [User!]
|
users: [User!]
|
||||||
nameAvailable(name: String!): Boolean!
|
nameAvailable(name: String!): Boolean!
|
||||||
topUsers(cursor: String, when: String, sort: String): Users
|
topUsers(cursor: String, when: String, by: String): Users
|
||||||
topCowboys(cursor: String): Users
|
topCowboys(cursor: String): Users
|
||||||
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
||||||
hasNewNotes: Boolean!
|
hasNewNotes: Boolean!
|
||||||
@ -19,7 +19,7 @@ export default gql`
|
|||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
setName(name: String!): Boolean
|
setName(name: String!): String
|
||||||
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
|
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
|
||||||
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
|
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
|
||||||
@ -45,6 +45,7 @@ export default gql`
|
|||||||
createdAt: String!
|
createdAt: String!
|
||||||
name: String
|
name: String
|
||||||
nitems(when: String): Int!
|
nitems(when: String): Int!
|
||||||
|
nposts(when: String): Int!
|
||||||
ncomments(when: String): Int!
|
ncomments(when: String): Int!
|
||||||
nbookmarks(when: String): Int!
|
nbookmarks(when: String): Int!
|
||||||
stacked(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`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
|
@ -1,27 +1,23 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import AvatarEditor from 'react-avatar-editor'
|
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 Upload from './upload'
|
||||||
import EditImage from '../svgs/image-edit-fill.svg'
|
import EditImage from '../svgs/image-edit-fill.svg'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export default function Avatar ({ onSuccess }) {
|
export default function Avatar ({ onSuccess }) {
|
||||||
const [uploading, setUploading] = useState()
|
const [uploading, setUploading] = useState()
|
||||||
const [editProps, setEditProps] = useState()
|
|
||||||
const ref = useRef()
|
const ref = useRef()
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
|
const showModal = useShowModal()
|
||||||
|
|
||||||
|
const Body = ({ onClose, file, upload }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='text-right mt-1 p-4'>
|
||||||
<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
|
<AvatarEditor
|
||||||
ref={ref} width={200} height={200}
|
ref={ref} width={200} height={200}
|
||||||
image={editProps?.file}
|
image={file}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -38,15 +34,18 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
editProps.upload(blob)
|
upload(blob)
|
||||||
setEditProps(null)
|
onClose()
|
||||||
}
|
}
|
||||||
}, 'image/jpeg')
|
}, 'image/jpeg')
|
||||||
}}
|
}}
|
||||||
>save
|
>save
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Body>
|
</div>
|
||||||
</Modal>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Upload
|
<Upload
|
||||||
as={({ onClick }) =>
|
as={({ onClick }) =>
|
||||||
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
||||||
@ -59,7 +58,7 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
setUploading(false)
|
setUploading(false)
|
||||||
}}
|
}}
|
||||||
onSelect={(file, upload) => {
|
onSelect={(file, upload) => {
|
||||||
setEditProps({ file, upload })
|
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
|
||||||
}}
|
}}
|
||||||
onSuccess={async key => {
|
onSuccess={async key => {
|
||||||
onSuccess && onSuccess(key)
|
onSuccess && onSuccess(key)
|
||||||
@ -69,6 +68,5 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
|
||||||
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
|
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import Countdown from './countdown'
|
import Countdown from './countdown'
|
||||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||||
import FeeButton, { EditFeeButton } from './fee-button'
|
import FeeButton, { EditFeeButton } from './fee-button'
|
||||||
@ -100,7 +99,6 @@ export function BountyForm ({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={6}
|
minRows={6}
|
||||||
hint={
|
hint={
|
||||||
editThreshold
|
editThreshold
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Form, MarkdownInput, SubmitButton } from '../components/form'
|
import { Form, MarkdownInput, SubmitButton } from '../components/form'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import styles from './reply.module.css'
|
import styles from './reply.module.css'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import { EditFeeButton } from './fee-button'
|
import { EditFeeButton } from './fee-button'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import Delete from './delete'
|
import Delete from './delete'
|
||||||
@ -47,7 +46,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
|
|||||||
>
|
>
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={6}
|
minRows={6}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
|
@ -3,7 +3,7 @@ import styles from './comment.module.css'
|
|||||||
import Text from './text'
|
import Text from './text'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Reply, { ReplyOnAnotherPage } from './reply'
|
import Reply, { ReplyOnAnotherPage } from './reply'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import UpVote from './upvote'
|
import UpVote from './upvote'
|
||||||
import Eye from '../svgs/eye-fill.svg'
|
import Eye from '../svgs/eye-fill.svg'
|
||||||
import EyeClose from '../svgs/eye-close-line.svg'
|
import EyeClose from '../svgs/eye-close-line.svg'
|
||||||
@ -28,8 +28,8 @@ function Parent ({ item, rootText }) {
|
|||||||
const ParentFrag = () => (
|
const ParentFrag = () => (
|
||||||
<>
|
<>
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<Link href={`/items/${item.parentId}`} passHref>
|
<Link href={`/items/${item.parentId}`} className='text-reset'>
|
||||||
<a className='text-reset'>parent</a>
|
parent
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -38,12 +38,12 @@ function Parent ({ item, rootText }) {
|
|||||||
<>
|
<>
|
||||||
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
|
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<Link href={`/items/${root.id}`} passHref>
|
<Link href={`/items/${root.id}`} className='text-reset'>
|
||||||
<a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
|
{rootText || 'on:'} {root?.title}
|
||||||
</Link>
|
</Link>
|
||||||
{root.subName &&
|
{root.subName &&
|
||||||
<Link href={`/~${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>}
|
</Link>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -54,32 +54,42 @@ const truncateString = (string = '', maxLength = 140) =>
|
|||||||
? `${string.substring(0, maxLength)} […]`
|
? `${string.substring(0, maxLength)} […]`
|
||||||
: string
|
: string
|
||||||
|
|
||||||
export function CommentFlat ({ item, ...props }) {
|
export function CommentFlat ({ item, rank, ...props }) {
|
||||||
const router = useRouter()
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
{rank
|
||||||
|
? (
|
||||||
|
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
|
||||||
|
{rank}
|
||||||
|
</div>)
|
||||||
|
: <div />}
|
||||||
<div
|
<div
|
||||||
className='clickToContext py-2'
|
className='clickToContext py-2'
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (ignoreClick(e)) {
|
if (ignoreClick(e)) return
|
||||||
return
|
router.push(href, as)
|
||||||
}
|
|
||||||
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}>
|
<RootProvider root={item.root}>
|
||||||
<Comment item={item} {...props} />
|
<Comment item={item} {...props} />
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +193,8 @@ export default function Comment ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{bottomedOut
|
{collapse !== 'yep' && (
|
||||||
|
bottomedOut
|
||||||
? <DepthLimit item={item} />
|
? <DepthLimit item={item} />
|
||||||
: (
|
: (
|
||||||
<div className={`${styles.children}`}>
|
<div className={`${styles.children}`}>
|
||||||
@ -200,6 +211,7 @@ export default function Comment ({
|
|||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -208,8 +220,8 @@ export default function Comment ({
|
|||||||
function DepthLimit ({ item }) {
|
function DepthLimit ({ item }) {
|
||||||
if (item.ncomments > 0) {
|
if (item.ncomments > 0) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} className='d-block p-3 font-weight-bold text-muted w-100 text-center'>
|
||||||
<a className='d-block p-3 font-weight-bold text-muted w-100 text-center'>view replies</a>
|
view replies
|
||||||
</Link>
|
</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 { gql, useApolloClient, useLazyQuery } from '@apollo/client'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Comment, { CommentSkeleton } from './comment'
|
import Comment, { CommentSkeleton } from './comment'
|
||||||
import styles from './header.module.css'
|
import styles from './header.module.css'
|
||||||
import { Nav, Navbar } from 'react-bootstrap'
|
import { Nav, Navbar } from 'react-bootstrap'
|
||||||
import { COMMENTS_QUERY } from '../fragments/items'
|
import { COMMENTS_QUERY } from '../fragments/items'
|
||||||
import { COMMENTS } from '../fragments/comments'
|
import { COMMENTS } from '../fragments/comments'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
|
import { defaultCommentSort } from '../lib/item'
|
||||||
|
|
||||||
export function CommentsHeader ({ handleSort, pinned, commentSats }) {
|
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||||
const [sort, setSort] = useState(pinned ? 'recent' : 'hot')
|
const [sort, setSort] = useState(defaultCommentSort(pinned, bio, parentCreatedAt))
|
||||||
|
|
||||||
const getHandleClick = sort => {
|
const getHandleClick = sort => {
|
||||||
return () => {
|
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()
|
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 [loading, setLoading] = useState()
|
||||||
const [getComments] = useLazyQuery(COMMENTS_QUERY, {
|
const [getComments] = useLazyQuery(COMMENTS_QUERY, {
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'cache-first',
|
||||||
onCompleted: data => {
|
onCompleted: data => {
|
||||||
client.writeFragment({
|
client.writeFragment({
|
||||||
id: `Item:${parentId}`,
|
id: `Item:${parentId}`,
|
||||||
@ -97,7 +91,8 @@ export default function Comments ({ parentId, pinned, commentSats, comments, ...
|
|||||||
<>
|
<>
|
||||||
{comments.length
|
{comments.length
|
||||||
? <CommentsHeader
|
? <CommentsHeader
|
||||||
commentSats={commentSats} pinned={pinned} handleSort={sort => {
|
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
|
||||||
|
pinned={pinned} bio={bio} handleSort={sort => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
getComments({ variables: { id: parentId, sort } })
|
getComments({ variables: { id: parentId, sort } })
|
||||||
}}
|
}}
|
||||||
|
@ -5,7 +5,7 @@ export default function SimpleCountdown ({ className, onComplete, date }) {
|
|||||||
<span className={className}>
|
<span className={className}>
|
||||||
<Countdown
|
<Countdown
|
||||||
date={date}
|
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}
|
onComplete={onComplete}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
17
components/dark-mode.js
Normal file
17
components/dark-mode.js
Normal file
@ -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 { useMutation } from '@apollo/client'
|
||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
import { Alert, Button, Dropdown } from 'react-bootstrap'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import Countdown from './countdown'
|
import Countdown from './countdown'
|
||||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||||
import FeeButton, { EditFeeButton } from './fee-button'
|
import FeeButton, { EditFeeButton } from './fee-button'
|
||||||
@ -43,9 +42,7 @@ export function DiscussionForm ({
|
|||||||
...ItemFields
|
...ItemFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`, {
|
}`)
|
||||||
fetchPolicy: 'network-only'
|
|
||||||
})
|
|
||||||
|
|
||||||
const related = relatedData?.related?.items || []
|
const related = relatedData?.related?.items || []
|
||||||
|
|
||||||
@ -96,7 +93,6 @@ export function DiscussionForm ({
|
|||||||
topLevel
|
topLevel
|
||||||
label={<>{textLabel} <small className='text-muted ml-2'>optional</small></>}
|
label={<>{textLabel} <small className='text-muted ml-2'>optional</small></>}
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={6}
|
minRows={6}
|
||||||
hint={editThreshold
|
hint={editThreshold
|
||||||
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
? <div className='text-muted font-weight-bold'><Countdown date={editThreshold} /></div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Component } from 'react'
|
import { Component } from 'react'
|
||||||
import LayoutStatic from './layout-static'
|
import { StaticLayout } from './layout'
|
||||||
import styles from '../styles/404.module.css'
|
import styles from '../styles/404.module.css'
|
||||||
|
|
||||||
class ErrorBoundary extends Component {
|
class ErrorBoundary extends Component {
|
||||||
@ -25,10 +25,10 @@ class ErrorBoundary extends Component {
|
|||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
// You can render any custom fallback UI
|
// You can render any custom fallback UI
|
||||||
return (
|
return (
|
||||||
<LayoutStatic>
|
<StaticLayout>
|
||||||
<Image width='500' height='375' src='/floating.gif' fluid />
|
<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>
|
<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
|
const query = parentId
|
||||||
? gql`{ itemRepetition(parentId: "${parentId}") }`
|
? gql`{ itemRepetition(parentId: "${parentId}") }`
|
||||||
: gql`{ itemRepetition }`
|
: gql`{ itemRepetition }`
|
||||||
const { data } = useQuery(query, { pollInterval: 1000 })
|
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||||
const repetition = data?.itemRepetition || 0
|
const repetition = data?.itemRepetition || 0
|
||||||
const formik = useFormikContext()
|
const formik = useFormikContext()
|
||||||
const boost = formik?.values?.boost || 0
|
const boost = formik?.values?.boost || 0
|
||||||
|
@ -10,14 +10,12 @@ const REWARDS = gql`
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
export default function Rewards () {
|
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
|
const total = data?.expectedRewards?.total
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href='/rewards' passHref>
|
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
|
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
import { useQuery } from '@apollo/client'
|
|
||||||
import gql from 'graphql-tag'
|
|
||||||
import { Container, OverlayTrigger, Popover } from 'react-bootstrap'
|
import { Container, OverlayTrigger, Popover } from 'react-bootstrap'
|
||||||
import { CopyInput } from './form'
|
import { CopyInput } from './form'
|
||||||
import styles from './footer.module.css'
|
import styles from './footer.module.css'
|
||||||
import Texas from '../svgs/texas.svg'
|
import Texas from '../svgs/texas.svg'
|
||||||
import Github from '../svgs/github-fill.svg'
|
import Github from '../svgs/github-fill.svg'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import useDarkMode from 'use-dark-mode'
|
|
||||||
import Sun from '../svgs/sun-fill.svg'
|
import Sun from '../svgs/sun-fill.svg'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
import No from '../svgs/no.svg'
|
import No from '../svgs/no.svg'
|
||||||
@ -14,70 +11,7 @@ import Bolt from '../svgs/bolt.svg'
|
|||||||
import Amboss from '../svgs/amboss.svg'
|
import Amboss from '../svgs/amboss.svg'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Rewards from './footer-rewards'
|
import Rewards from './footer-rewards'
|
||||||
|
import useDarkMode from './dark-mode'
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const RssPopover = (
|
const RssPopover = (
|
||||||
<Popover>
|
<Popover>
|
||||||
@ -179,33 +113,19 @@ const AnalyticsPopover = (
|
|||||||
visitors
|
visitors
|
||||||
</a>
|
</a>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<Link href='/stackers/day' passHref>
|
<Link href='/stackers/day' className='nav-link p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 d-inline-flex'>
|
|
||||||
stackers
|
stackers
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function Footer ({ noLinks }) {
|
export default function Footer ({ links = true }) {
|
||||||
const query = gql`
|
const [darkMode, darkModeToggle] = useDarkMode()
|
||||||
{
|
|
||||||
connectAddress
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const { data } = useQuery(query, { fetchPolicy: 'cache-first' })
|
|
||||||
|
|
||||||
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)
|
const [lightning, setLightning] = useState(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
|
||||||
setLightning(localStorage.getItem('lnAnimate') || 'yes')
|
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 LnIcon = lightning === 'yes' ? No : Bolt
|
||||||
|
|
||||||
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
|
||||||
@ -227,13 +147,12 @@ export default function Footer ({ noLinks }) {
|
|||||||
return (
|
return (
|
||||||
<footer>
|
<footer>
|
||||||
<Container className='mb-3 mt-4'>
|
<Container className='mb-3 mt-4'>
|
||||||
{!noLinks &&
|
{links &&
|
||||||
<>
|
<>
|
||||||
{mounted &&
|
|
||||||
<div className='mb-1'>
|
<div className='mb-1'>
|
||||||
<DarkModeIcon onClick={() => darkMode.toggle()} width={20} height={20} className='fill-grey theme' />
|
<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' />
|
<LnIcon onClick={toggleLightning} width={20} height={20} className='ml-2 fill-grey theme' suppressHydrationWarning />
|
||||||
</div>}
|
</div>
|
||||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||||
<Rewards />
|
<Rewards />
|
||||||
</div>
|
</div>
|
||||||
@ -263,38 +182,28 @@ export default function Footer ({ noLinks }) {
|
|||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
</div>
|
</div>
|
||||||
<div className='mb-2' style={{ fontWeight: 500 }}>
|
<div className='mb-2' style={{ fontWeight: 500 }}>
|
||||||
<Link href='/faq' passHref>
|
<Link href='/faq' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
faq
|
faq
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<Link href='/guide' passHref>
|
<Link href='/guide' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
guide
|
guide
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<Link href='/story' passHref>
|
<Link href='/story' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
story
|
story
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<Link href='/changes' passHref>
|
<Link href='/changes' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
changes
|
changes
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span className='mx-2 text-muted'> \ </span>
|
<span className='mx-2 text-muted'> \ </span>
|
||||||
<Link href='/privacy' passHref>
|
<Link href='/privacy' className='nav-link p-0 p-0 d-inline-flex'>
|
||||||
<a className='nav-link p-0 p-0 d-inline-flex'>
|
|
||||||
privacy
|
privacy
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
{data &&
|
{process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS &&
|
||||||
<div
|
<div
|
||||||
className={`text-small mx-auto mb-2 ${styles.connect}`}
|
className={`text-small mx-auto mb-2 ${styles.connect}`}
|
||||||
>
|
>
|
||||||
@ -304,7 +213,7 @@ export default function Footer ({ noLinks }) {
|
|||||||
groupClassName='mb-0 w-100'
|
groupClassName='mb-0 w-100'
|
||||||
readOnly
|
readOnly
|
||||||
noForm
|
noForm
|
||||||
placeholder={data.connectAddress}
|
placeholder={process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href='https://amboss.space/node/03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02'
|
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} />
|
made in Austin<Texas className='ml-1' width={20} height={20} />
|
||||||
<span className='ml-1'>by</span>
|
<span className='ml-1'>by</span>
|
||||||
<span>
|
<span>
|
||||||
<Link href='/k00b' passHref>
|
<Link href='/k00b' className='ml-1'>
|
||||||
<a className='ml-1'>@k00b</a>
|
@k00b
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/kr' passHref>
|
<Link href='/kr' className='ml-1'>
|
||||||
<a className='ml-1'>@kr</a>
|
@kr
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/ekzyis' passHref>
|
<Link href='/ekzyis' className='ml-1'>
|
||||||
<a className='ml-1'>@ekzyis</a>
|
@ekzyis
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</small>
|
</small>
|
||||||
|
@ -15,6 +15,7 @@ import { mdHas } from '../lib/md'
|
|||||||
import CloseIcon from '../svgs/close-line.svg'
|
import CloseIcon from '../svgs/close-line.svg'
|
||||||
import { useLazyQuery } from '@apollo/client'
|
import { useLazyQuery } from '@apollo/client'
|
||||||
import { USER_SEARCH } from '../fragments/users'
|
import { USER_SEARCH } from '../fragments/users'
|
||||||
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
|
|
||||||
export function SubmitButton ({
|
export function SubmitButton ({
|
||||||
children, variant, value, onClick, disabled, ...props
|
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 })
|
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
||||||
innerRef = innerRef || useRef(null)
|
innerRef = innerRef || useRef(null)
|
||||||
|
|
||||||
|
props.as ||= TextareaAutosize
|
||||||
|
props.rows ||= props.minRows || 6
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!meta.value && setTab('write')
|
!meta.value && setTab('write')
|
||||||
}, [meta.value])
|
}, [meta.value])
|
||||||
@ -111,7 +115,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||||||
<Markdown width={18} height={18} />
|
<Markdown width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</Nav>
|
</Nav>
|
||||||
<div className={tab !== 'write' ? 'd-none' : ''}>
|
{tab === 'write'
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
<InputInner
|
<InputInner
|
||||||
{...props} onChange={(formik, e) => {
|
{...props} onChange={(formik, e) => {
|
||||||
if (onChange) onChange(formik, e)
|
if (onChange) onChange(formik, e)
|
||||||
@ -147,12 +153,15 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||||||
if (onKeyDown) onKeyDown(e)
|
if (onKeyDown) onKeyDown(e)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>)
|
||||||
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
|
: (
|
||||||
|
<div className='form-group'>
|
||||||
<div className={`${styles.text} form-control`}>
|
<div className={`${styles.text} form-control`}>
|
||||||
{tab === 'preview' && <Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>}
|
<Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
@ -300,7 +309,6 @@ function InputInner ({
|
|||||||
|
|
||||||
export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
||||||
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
onCompleted: data => {
|
onCompleted: data => {
|
||||||
setSuggestions({ array: data.searchUsers, index: 0 })
|
setSuggestions({ array: data.searchUsers, index: 0 })
|
||||||
}
|
}
|
||||||
@ -476,10 +484,17 @@ export function Form ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
|
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
|
||||||
const [field, meta] = noForm ? [{}, {}] : useField(props)
|
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
||||||
const formik = noForm ? null : useFormikContext()
|
const formik = noForm ? null : useFormikContext()
|
||||||
const invalid = meta.touched && meta.error
|
const invalid = meta.touched && meta.error
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (overrideValue) {
|
||||||
|
helpers.setValue(overrideValue)
|
||||||
|
}
|
||||||
|
}, [overrideValue])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<BootstrapForm.Control
|
<BootstrapForm.Control
|
||||||
|
@ -8,11 +8,11 @@ import Price from './price'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { signOut } from 'next-auth/client'
|
import { signOut } from 'next-auth/client'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { randInRange } from '../lib/rand'
|
import { randInRange } from '../lib/rand'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
import NoteIcon from '../svgs/notification-4-fill.svg'
|
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 LightningIcon from '../svgs/bolt.svg'
|
||||||
import CowboyHat from './cowboy-hat'
|
import CowboyHat from './cowboy-hat'
|
||||||
import { Form, Select } from './form'
|
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 BackArrow from '../svgs/arrow-left-line.svg'
|
||||||
import { SUBS } from '../lib/constants'
|
import { SUBS } from '../lib/constants'
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
|
import { HAS_NOTIFICATIONS } from '../fragments/notifications'
|
||||||
|
|
||||||
function WalletSummary ({ me }) {
|
function WalletSummary ({ me }) {
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
|
|
||||||
return `${abbrNum(me.sats)}`
|
return `${abbrNum(me.sats)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,75 +42,48 @@ function Back () {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header ({ sub }) {
|
function NotificationBell () {
|
||||||
const router = useRouter()
|
const { data } = useQuery(HAS_NOTIFICATIONS, {
|
||||||
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
|
|
||||||
}
|
|
||||||
`, {
|
|
||||||
pollInterval: 30000,
|
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 (
|
return (
|
||||||
<div className='d-flex align-items-center ml-auto'>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel='shortcut icon' href={hasNewNotes?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
<link rel='shortcut icon' href={data?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||||
</Head>
|
</Head>
|
||||||
<Link href='/notifications' passHref>
|
<Link href='/notifications' passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
||||||
<NoteIcon height={22} width={22} className='theme' />
|
<NoteIcon height={22} width={22} className='theme' />
|
||||||
{hasNewNotes?.hasNewNotes &&
|
{data?.hasNewNotes &&
|
||||||
<span className={styles.notification}>
|
<span className={styles.notification}>
|
||||||
<span className='invisible'>{' '}</span>
|
<span className='invisible'>{' '}</span>
|
||||||
</span>}
|
</span>}
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackerCorner ({ dropNavKey }) {
|
||||||
|
const me = useMe()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='d-flex align-items-center ml-auto'>
|
||||||
|
<NotificationBell />
|
||||||
<div className='position-relative'>
|
<div className='position-relative'>
|
||||||
<NavDropdown
|
<NavDropdown
|
||||||
className={styles.dropdown} title={
|
className={styles.dropdown}
|
||||||
<Nav.Link eventKey={me?.name} as='span' className='p-0 d-flex align-items-center' onClick={e => e.preventDefault()}>
|
title={
|
||||||
{`@${me?.name}`}<CowboyHat user={me} />
|
<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>
|
</Nav.Link>
|
||||||
} alignRight
|
}
|
||||||
|
alignRight
|
||||||
>
|
>
|
||||||
<Link href={'/' + me?.name} passHref>
|
<Link href={'/' + me.name} passHref legacyBehavior>
|
||||||
<NavDropdown.Item active={me?.name === dropNavKey}>
|
<NavDropdown.Item active={me.name === dropNavKey}>
|
||||||
profile
|
profile
|
||||||
{me && !me.bioId &&
|
{me && !me.bioId &&
|
||||||
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
<div className='p-1 d-inline-block bg-secondary ml-1'>
|
||||||
@ -118,58 +91,61 @@ export default function Header ({ sub }) {
|
|||||||
</div>}
|
</div>}
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={'/' + me?.name + '/bookmarks'} passHref>
|
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
|
||||||
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
<NavDropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/wallet' passHref>
|
<Link href='/wallet' passHref legacyBehavior>
|
||||||
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
|
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref legacyBehavior>
|
||||||
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<Link href='/referrals/month' passHref>
|
<Link href='/referrals/month' passHref legacyBehavior>
|
||||||
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<Link href='/settings' passHref>
|
<Link href='/settings' passHref legacyBehavior>
|
||||||
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
|
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
|
||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
{me && !me.bioId &&
|
{!me.bioId &&
|
||||||
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
|
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
|
||||||
<span className='invisible'>{' '}</span>
|
<span className='invisible'>{' '}</span>
|
||||||
</span>}
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
{me &&
|
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Link href='/wallet' passHref>
|
<Link href='/wallet' passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
|
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>}
|
</Nav.Item>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
if (!fired) {
|
|
||||||
|
function LurkerCorner ({ path }) {
|
||||||
|
const router = useRouter()
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
|
||||||
if (!localStorage.getItem('striked')) {
|
if (!localStorage.getItem('striked')) {
|
||||||
setTimeout(() => {
|
const to = setTimeout(() => {
|
||||||
if (isMounted) {
|
|
||||||
strike()
|
strike()
|
||||||
localStorage.setItem('striked', 'yep')
|
localStorage.setItem('striked', 'yep')
|
||||||
setFired(true)
|
|
||||||
}
|
|
||||||
}, randInRange(3000, 10000))
|
}, randInRange(3000, 10000))
|
||||||
|
return () => clearTimeout(to)
|
||||||
}
|
}
|
||||||
return () => { isMounted = false }
|
|
||||||
}, [])
|
}, [])
|
||||||
}
|
|
||||||
|
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') &&
|
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||||
<div className='ml-auto'>
|
<div className='ml-auto'>
|
||||||
<Button
|
<Button
|
||||||
@ -177,7 +153,7 @@ export default function Header ({ sub }) {
|
|||||||
id='signup'
|
id='signup'
|
||||||
style={{ borderWidth: '2px' }}
|
style={{ borderWidth: '2px' }}
|
||||||
variant='outline-grey-darkmode'
|
variant='outline-grey-darkmode'
|
||||||
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
|
onClick={() => handleLogin('/login')}
|
||||||
>
|
>
|
||||||
login
|
login
|
||||||
</Button>
|
</Button>
|
||||||
@ -185,7 +161,7 @@ export default function Header ({ sub }) {
|
|||||||
className='align-items-center pl-2 py-1 pr-3'
|
className='align-items-center pl-2 py-1 pr-3'
|
||||||
style={{ borderWidth: '2px' }}
|
style={{ borderWidth: '2px' }}
|
||||||
id='login'
|
id='login'
|
||||||
onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })}
|
onClick={() => handleLogin('/signup')}
|
||||||
>
|
>
|
||||||
<LightningIcon
|
<LightningIcon
|
||||||
width={17}
|
width={17}
|
||||||
@ -195,77 +171,68 @@ export default function Header ({ sub }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
|
function NavItems ({ className, sub, prefix }) {
|
||||||
// (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
|
const router = useRouter()
|
||||||
|
sub ||= 'home'
|
||||||
|
|
||||||
const NavItems = ({ className }) => {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{ sub }}
|
||||||
sub: sub || 'home'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
|
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
|
||||||
name='sub'
|
name='sub'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
overrideValue={sub}
|
||||||
items={['home', ...SUBS]}
|
items={['home', ...SUBS]}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
<Link href={prefix + '/'} passHref>
|
<Link href={prefix + '/'} passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
|
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
<Link href={prefix + '/recent'} passHref>
|
<Link href={prefix + '/recent'} passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
{sub !== 'jobs' &&
|
{sub !== 'jobs' &&
|
||||||
<Nav.Item className={className}>
|
<Nav.Item className={className}>
|
||||||
<Link href={prefix + '/top/posts/day'} passHref>
|
<Link href={prefix + '/top/posts/day'} passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
</Nav.Item>}
|
</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 }) => {
|
function PostItem ({ className, prefix }) {
|
||||||
return me
|
const me = useMe()
|
||||||
? (
|
if (!me) return null
|
||||||
<Link href={prefix + '/post'} passHref>
|
|
||||||
<a className={`${className} btn btn-md btn-primary px-3 py-1 `}>post</a>
|
|
||||||
</Link>)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Link href={prefix + '/post'} className={`${className} btn btn-md btn-primary px-3 py-1 `}>
|
||||||
<Container className='px-0'>
|
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'>
|
<Navbar className='pb-0 pb-lg-2'>
|
||||||
<Nav
|
<Nav
|
||||||
className={styles.navbarNav}
|
className={styles.navbarNav}
|
||||||
@ -273,15 +240,15 @@ export default function Header ({ sub }) {
|
|||||||
>
|
>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<Back />
|
<Back />
|
||||||
<Link href='/' passHref>
|
<Link href='/' passHref legacyBehavior>
|
||||||
<Navbar.Brand className={`${styles.brand} d-flex`}>
|
<Navbar.Brand className={`${styles.brand} d-flex`}>
|
||||||
SN
|
SN
|
||||||
</Navbar.Brand>
|
</Navbar.Brand>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<NavItems className='d-none d-lg-flex mx-2' />
|
<NavItems className='d-none d-lg-flex mx-2' prefix={prefix} sub={sub} />
|
||||||
<PostItem className='d-none d-lg-flex mx-2' />
|
<PostItem className='d-none d-lg-flex mx-2' prefix={prefix} />
|
||||||
<Link href='/search' passHref>
|
<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'>
|
<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} />
|
<SearchIcon className='theme' width={22} height={22} />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
@ -289,7 +256,7 @@ export default function Header ({ sub }) {
|
|||||||
<Nav.Item className={`${styles.price} ml-auto align-items-center ${me?.name.length > 10 ? 'd-none d-lg-flex' : ''}`}>
|
<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' />
|
<Price className='nav-link text-monospace' />
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Corner />
|
{me ? <StackerCorner dropNavKey={dropNavKey} /> : <LurkerCorner path={path} />}
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
<Navbar className='pt-0 pb-2 d-lg-none'>
|
<Navbar className='pt-0 pb-2 d-lg-none'>
|
||||||
@ -297,36 +264,35 @@ export default function Header ({ sub }) {
|
|||||||
className={`${styles.navbarNav}`}
|
className={`${styles.navbarNav}`}
|
||||||
activeKey={topNavKey}
|
activeKey={topNavKey}
|
||||||
>
|
>
|
||||||
<NavItems className='mr-1' />
|
<NavItems className='mr-1' prefix={prefix} sub={sub} />
|
||||||
<Link href='/search' passHref>
|
<Link href={prefix + '/search'} passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
|
<Nav.Link eventKey='search' className='position-relative ml-auto d-flex mr-1'>
|
||||||
<SearchIcon className='theme' width={22} height={22} />
|
<SearchIcon className='theme' width={22} height={22} />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Link>
|
</Link>
|
||||||
<PostItem className='mr-0 pr-0' />
|
<PostItem className='mr-0 pr-0' prefix={prefix} />
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderStatic () {
|
export function HeaderStatic () {
|
||||||
return (
|
return (
|
||||||
<Container className='px-sm-0'>
|
<Container as='header' className='px-sm-0'>
|
||||||
<Navbar className='pb-0 pb-lg-1'>
|
<Navbar className='pb-0 pb-lg-1'>
|
||||||
<Nav
|
<Nav
|
||||||
className={styles.navbarNav}
|
className={styles.navbarNav}
|
||||||
>
|
>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<Back />
|
<Back />
|
||||||
<Link href='/' passHref>
|
<Link href='/' passHref legacyBehavior>
|
||||||
<Navbar.Brand className={`${styles.brand}`}>
|
<Navbar.Brand className={`${styles.brand}`}>
|
||||||
SN
|
SN
|
||||||
</Navbar.Brand>
|
</Navbar.Brand>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href='/search' passHref>
|
<Link href='/search' passHref legacyBehavior>
|
||||||
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
|
<Nav.Link eventKey='search' className='position-relative d-flex align-items-center mx-2'>
|
||||||
<SearchIcon className='theme' width={22} height={22} />
|
<SearchIcon className='theme' width={22} height={22} />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
|
@ -1,28 +1,16 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Modal } from 'react-bootstrap'
|
|
||||||
import InfoIcon from '../svgs/information-fill.svg'
|
import InfoIcon from '../svgs/information-fill.svg'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export default function Info ({ children, iconClassName = 'fill-theme-color' }) {
|
export default function Info ({ children, iconClassName = 'fill-theme-color' }) {
|
||||||
const [info, setInfo] = useState()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
show={info}
|
|
||||||
onHide={() => setInfo(false)}
|
|
||||||
>
|
|
||||||
<div className='modal-close' onClick={() => setInfo(false)}>X</div>
|
|
||||||
<Modal.Body>
|
|
||||||
{children}
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal>
|
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
width={18} height={18} className={`${iconClassName} pointer ml-1`}
|
width={18} height={18} className={`${iconClassName} pointer ml-1`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setInfo(true)
|
showModal(onClose => children)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,13 @@ import Comment from './comment'
|
|||||||
import Text, { ZoomableImage } from './text'
|
import Text, { ZoomableImage } from './text'
|
||||||
import Comments from './comments'
|
import Comments from './comments'
|
||||||
import styles from '../styles/item.module.css'
|
import styles from '../styles/item.module.css'
|
||||||
|
import itemStyles from './item.module.css'
|
||||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||||
import YouTube from 'react-youtube'
|
import YouTube from 'react-youtube'
|
||||||
import useDarkMode from 'use-dark-mode'
|
import useDarkMode from './dark-mode'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Poll from './poll'
|
import Poll from './poll'
|
||||||
import { commentsViewed } from '../lib/new-comments'
|
import { commentsViewed } from '../lib/new-comments'
|
||||||
@ -61,7 +62,7 @@ function TweetSkeleton () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ItemEmbed ({ item }) {
|
function ItemEmbed ({ item }) {
|
||||||
const darkMode = useDarkMode()
|
const [darkMode] = useDarkMode()
|
||||||
const [overflowing, setOverflowing] = useState(false)
|
const [overflowing, setOverflowing] = useState(false)
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
@ -69,7 +70,7 @@ function ItemEmbed ({ item }) {
|
|||||||
if (twitter?.groups?.id) {
|
if (twitter?.groups?.id) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.twitterContainer} ${show ? '' : styles.twitterContained}`}>
|
<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 &&
|
{overflowing && !show &&
|
||||||
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
|
||||||
show full tweet
|
show full tweet
|
||||||
@ -104,8 +105,8 @@ function FwdUser ({ user }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.other}>
|
<div className={styles.other}>
|
||||||
100% of zaps are forwarded to{' '}
|
100% of zaps are forwarded to{' '}
|
||||||
<Link href={`/${user.name}`} passHref>
|
<Link href={`/${user.name}`}>
|
||||||
<a>@{user.name}</a>
|
@{user.name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -119,6 +120,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
|
|||||||
item={item}
|
item={item}
|
||||||
full
|
full
|
||||||
right={
|
right={
|
||||||
|
!noReply &&
|
||||||
<>
|
<>
|
||||||
<Share item={item} />
|
<Share item={item} />
|
||||||
<Toc text={item.text} />
|
<Toc text={item.text} />
|
||||||
@ -158,12 +160,19 @@ function ItemText ({ item }) {
|
|||||||
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.searchText || item.text}</Text>
|
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(() => {
|
useEffect(() => {
|
||||||
commentsViewed(item)
|
commentsViewed(item)
|
||||||
}, [item.lastCommentAt])
|
}, [item.lastCommentAt])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{rank
|
||||||
|
? (
|
||||||
|
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
|
||||||
|
{rank}
|
||||||
|
</div>)
|
||||||
|
: <div />}
|
||||||
<RootProvider root={item.root || item}>
|
<RootProvider root={item.root || item}>
|
||||||
{item.parentId
|
{item.parentId
|
||||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||||
@ -176,8 +185,12 @@ export default function ItemFull ({ item, bio, ...props }) {
|
|||||||
</div>)}
|
</div>)}
|
||||||
{item.comments &&
|
{item.comments &&
|
||||||
<div className={styles.comments}>
|
<div className={styles.comments}>
|
||||||
<Comments parentId={item.id} pinned={item.position} commentSats={item.commentSats} comments={item.comments} />
|
<Comments
|
||||||
|
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||||
|
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||||
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -41,43 +41,39 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
|
|||||||
<span>{abbrNum(item.boost)} boost</span>
|
<span>{abbrNum(item.boost)} boost</span>
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
</>}
|
</>}
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} title={`${item.commentSats} sats`} className='text-reset'>
|
||||||
<a title={`${item.commentSats} sats`} className='text-reset'>
|
|
||||||
{item.ncomments} {commentsText || 'comments'}
|
{item.ncomments} {commentsText || 'comments'}
|
||||||
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
{hasNewComments && <>{' '}<Badge className={styles.newComment} variant={null}>new</Badge></>}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<span>
|
<span>
|
||||||
<Link href={`/${item.user.name}`} passHref>
|
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
|
||||||
<a className='d-inline-flex align-items-center'>
|
|
||||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||||
{embellishUser}
|
{embellishUser}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
{timeSince(new Date(item.createdAt))}
|
||||||
</Link>
|
</Link>
|
||||||
{item.prior &&
|
{item.prior &&
|
||||||
<>
|
<>
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<Link href={`/items/${item.prior}`} passHref>
|
<Link href={`/items/${item.prior}`} className='text-reset'>
|
||||||
<a className='text-reset'>yesterday</a>
|
yesterday
|
||||||
</Link>
|
</Link>
|
||||||
</>}
|
</>}
|
||||||
</span>
|
</span>
|
||||||
{item.subName &&
|
{item.subName &&
|
||||||
<Link href={`/~${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>}
|
</Link>}
|
||||||
{(item.outlawed && !item.mine &&
|
{(item.outlawed && !item.mine &&
|
||||||
<Link href='/outlawed'>
|
<Link href='/recent/outlawed'>
|
||||||
<a>{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge></a>
|
{' '}<Badge className={styles.newComment} variant={null}>outlawed</Badge>
|
||||||
</Link>) ||
|
</Link>) ||
|
||||||
(item.freebie && !item.mine &&
|
(item.freebie &&
|
||||||
<Link href='/freebie'>
|
<Link href='/recent/freebies'>
|
||||||
<a>{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge></a>
|
{' '}<Badge className={styles.newComment} variant={null}>freebie</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{canEdit && !item.deletedAt &&
|
{canEdit && !item.deletedAt &&
|
||||||
@ -101,11 +97,9 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
|
|||||||
{me && <BookmarkDropdownItem item={item} />}
|
{me && <BookmarkDropdownItem item={item} />}
|
||||||
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
|
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
|
||||||
{item.otsHash &&
|
{item.otsHash &&
|
||||||
<Dropdown.Item>
|
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
|
||||||
<Link passHref href={`/items/${item.id}/ots`}>
|
ots timestamp
|
||||||
<a className='text-reset'>ots timestamp</a>
|
</Link>}
|
||||||
</Link>
|
|
||||||
</Dropdown.Item>}
|
|
||||||
{me && !item.meSats && !item.position && !item.meDontLike &&
|
{me && !item.meSats && !item.position && !item.meDontLike &&
|
||||||
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
||||||
{item.mine && !item.position && !item.deletedAt &&
|
{item.mine && !item.position && !item.deletedAt &&
|
||||||
|
@ -21,22 +21,18 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<div className={`${styles.item}`}>
|
<div className={`${styles.item}`}>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`}>
|
||||||
<a>
|
|
||||||
<Image
|
<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}
|
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>
|
</Link>
|
||||||
<div className={`${styles.hunk} align-self-center mb-0`}>
|
<div className={`${styles.hunk} align-self-center mb-0`}>
|
||||||
<div className={`${styles.main} flex-wrap d-inline`}>
|
<div className={`${styles.main} flex-wrap d-inline`}>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} className={`${styles.title} text-reset mr-2`}>
|
||||||
<a className={`${styles.title} text-reset mr-2`}>
|
|
||||||
{item.searchTitle
|
{item.searchTitle
|
||||||
? <SearchTitle title={item.searchTitle} />
|
? <SearchTitle title={item.searchTitle} />
|
||||||
: (
|
: (
|
||||||
<>{item.title}</>)}
|
<>{item.title}</>)}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles.other}`}>
|
<div className={`${styles.other}`}>
|
||||||
@ -52,14 +48,12 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||||||
<wbr />
|
<wbr />
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<span>
|
<span>
|
||||||
<Link href={`/${item.user.name}`} passHref>
|
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
|
||||||
<a className='d-inline-flex align-items-center'>
|
|
||||||
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
@{item.user.name}<CowboyHat className='ml-1 fill-grey' user={item.user} height={12} width={12} />
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset'>
|
||||||
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
|
{timeSince(new Date(item.createdAt))}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
{item.mine &&
|
{item.mine &&
|
||||||
@ -67,10 +61,8 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||||||
<>
|
<>
|
||||||
<wbr />
|
<wbr />
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
<Link href={`/items/${item.id}/edit`} passHref>
|
<Link href={`/items/${item.id}/edit`} className='text-reset'>
|
||||||
<a className='text-reset'>
|
|
||||||
edit
|
edit
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
{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 titleRef = useRef()
|
||||||
const [pendingSats, setPendingSats] = useState(0)
|
const [pendingSats, setPendingSats] = useState(0)
|
||||||
|
|
||||||
@ -33,14 +33,13 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
|
|||||||
{rank}
|
{rank}
|
||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<div className={styles.item}>
|
<div className={`${styles.item} ${siblingComments ? 'pt-2' : ''}`}>
|
||||||
{item.position
|
{item.position
|
||||||
? <Pin width={24} height={24} className={styles.pin} />
|
? <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} />}
|
: 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.hunk}>
|
||||||
<div className={`${styles.main} flex-wrap`}>
|
<div className={`${styles.main} flex-wrap`}>
|
||||||
<Link href={`/items/${item.id}`} passHref>
|
<Link href={`/items/${item.id}`} ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
||||||
<a ref={titleRef} className={`${styles.title} text-reset mr-2`}>
|
|
||||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
{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.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ml-1' height={14} width={14} /></span>}
|
||||||
{item.bounty > 0 &&
|
{item.bounty > 0 &&
|
||||||
@ -50,7 +49,6 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
|
|||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
</span>}
|
</span>}
|
||||||
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
|
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
{item.url && !image &&
|
{item.url && !image &&
|
||||||
<>
|
<>
|
||||||
|
@ -70,7 +70,7 @@ a.link:visited {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-bottom: .45rem;
|
padding-bottom: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item .companyImage {
|
.item .companyImage {
|
||||||
@ -120,7 +120,7 @@ a.link:visited {
|
|||||||
|
|
||||||
.rank {
|
.rank {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 4px;
|
margin-top: .25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
color: var(--theme-grey);
|
color: var(--theme-grey);
|
||||||
font-size: 90%;
|
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 Item, { ItemSkeleton } from './item'
|
||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import styles from './items.module.css'
|
import styles from './items.module.css'
|
||||||
import { ITEMS } from '../fragments/items'
|
|
||||||
import MoreFooter from './more-footer'
|
import MoreFooter from './more-footer'
|
||||||
import { Fragment } from 'react'
|
import { Fragment, useCallback, useMemo } from 'react'
|
||||||
import { CommentFlat } from './comment'
|
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 }) {
|
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
|
||||||
const { data, fetchMore } = useQuery(query || ITEMS, { variables })
|
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
|
||||||
|
const Foooter = Footer || MoreFooter
|
||||||
|
|
||||||
if (!data && !items) {
|
const { items, pins, cursor } = useMemo(() => {
|
||||||
return <ItemsSkeleton rank={rank} />
|
if (!data && !ssrData) return {}
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
if (destructureData) {
|
if (destructureData) {
|
||||||
({ items, pins, cursor } = destructureData(data))
|
return destructureData(data || ssrData)
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{items.map((item, i) => (
|
{items.filter(filter).map((item, i) => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
|
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
|
||||||
{item.parentId
|
{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
|
: (item.isJob
|
||||||
? <ItemJob item={item} rank={rank && i + 1} />
|
? <ItemJob item={item} rank={rank && i + 1} />
|
||||||
: (item.title
|
: (item.searchText
|
||||||
? <Item item={item} rank={rank && i + 1} />
|
? <ItemFull item={item} rank={rank && i + 1} noReply siblingComments={variables.includeComments} />
|
||||||
: (
|
: <Item item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />))}
|
||||||
<div className='pb-2'>
|
|
||||||
<CommentFlat item={item} noReply includeParent clickToContext />
|
|
||||||
</div>)))}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<MoreFooter
|
<Foooter
|
||||||
cursor={cursor} fetchMore={fetchMore}
|
cursor={cursor} fetchMore={fetchMore} noMoreText={noMoreText}
|
||||||
Skeleton={() => <ItemsSkeleton rank={rank} startRank={items.length} />}
|
count={items?.length}
|
||||||
|
Skeleton={Skeleton}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemsSkeleton ({ rank, startRank = 0 }) {
|
export function ItemsSkeleton ({ rank, startRank = 0, limit = LIMIT }) {
|
||||||
const items = new Array(21).fill(null)
|
const items = new Array(limit).fill(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
|
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 { InputGroup, Form as BForm, Col, Image } from 'react-bootstrap'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
@ -133,7 +132,6 @@ export default function JobForm ({ item, sub }) {
|
|||||||
topLevel
|
topLevel
|
||||||
label='description'
|
label='description'
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={6}
|
minRows={6}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -170,7 +168,7 @@ function PromoteJob ({ item, sub, storageKeyPrefix }) {
|
|||||||
query AuctionPosition($id: ID, $bid: Int!) {
|
query AuctionPosition($id: ID, $bid: Int!) {
|
||||||
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
|
||||||
}`,
|
}`,
|
||||||
{ fetchPolicy: 'network-only' })
|
{ fetchPolicy: 'cache-and-network' })
|
||||||
const position = data?.auctionPosition
|
const position = data?.auctionPosition
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -260,7 +258,7 @@ function StatusControl ({ item }) {
|
|||||||
<div className='p-3'>
|
<div className='p-3'>
|
||||||
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
<BootstrapForm.Label>job control</BootstrapForm.Label>
|
||||||
{item.status === 'NOSATS' &&
|
{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 />
|
<StatusComp />
|
||||||
</div>
|
</div>
|
||||||
</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 Container from 'react-bootstrap/Container'
|
||||||
import { LightningProvider } from './lightning'
|
|
||||||
import Footer from './footer'
|
import Footer from './footer'
|
||||||
import Seo from './seo'
|
import Seo, { SeoSearch } from './seo'
|
||||||
import Search from './search'
|
import Search from './search'
|
||||||
|
import styles from './layout.module.css'
|
||||||
|
|
||||||
export default function Layout ({
|
export default function Layout ({
|
||||||
sub, noContain, noFooter, noFooterLinks,
|
sub, contain = true, footer = true, footerLinks = true,
|
||||||
containClassName, noSeo, children, search
|
containClassName = '', seo = true, item, user, children
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!noSeo && <Seo sub={sub} />}
|
{seo && <Seo sub={sub} item={item} user={user} />}
|
||||||
<LightningProvider>
|
|
||||||
<Header sub={sub} />
|
<Header sub={sub} />
|
||||||
{noContain
|
{contain
|
||||||
? children
|
? (
|
||||||
: (
|
<Container as='main' className={`px-sm-0 ${containClassName}`}>
|
||||||
<Container className={`px-sm-0 ${containClassName || ''}`}>
|
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)
|
||||||
{!noFooter && <Footer noLinks={noFooterLinks} />}
|
: children}
|
||||||
{!noContain && search && <Search sub={sub} />}
|
{footer && <Footer links={footerLinks} />}
|
||||||
</LightningProvider>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
k1
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
const { data } = useQuery(query, { pollInterval: 1000 })
|
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||||
|
|
||||||
if (data && data.lnAuth.pubkey) {
|
if (data && data.lnAuth.pubkey) {
|
||||||
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
|
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })
|
||||||
|
@ -1,32 +1,37 @@
|
|||||||
import React, { useRef, useEffect, useContext } from 'react'
|
import React, { useRef, useEffect, useContext } from 'react'
|
||||||
import { randInRange } from '../lib/rand'
|
import { randInRange } from '../lib/rand'
|
||||||
|
|
||||||
export const LightningContext = React.createContext({
|
export const LightningContext = React.createContext(() => {})
|
||||||
bolts: 0,
|
|
||||||
strike: () => {}
|
|
||||||
})
|
|
||||||
|
|
||||||
export class LightningProvider extends React.Component {
|
export class LightningProvider extends React.Component {
|
||||||
state = {
|
state = {
|
||||||
bolts: 0,
|
bolts: []
|
||||||
strike: (repeat) => {
|
}
|
||||||
|
|
||||||
|
strike = () => {
|
||||||
const should = localStorage.getItem('lnAnimate') || 'yes'
|
const should = localStorage.getItem('lnAnimate') || 'yes'
|
||||||
if (should === 'yes') {
|
if (should === 'yes') {
|
||||||
this.setState(state => {
|
this.setState(state => {
|
||||||
return {
|
return {
|
||||||
...this.state,
|
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
|
||||||
bolts: this.state.bolts + 1
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unstrike = (index) => {
|
||||||
|
this.setState(state => {
|
||||||
|
const bolts = [...state.bolts]
|
||||||
|
bolts[index] = null
|
||||||
|
return { bolts }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { state, props: { children } } = this
|
const { props: { children } } = this
|
||||||
return (
|
return (
|
||||||
<LightningContext.Provider value={state}>
|
<LightningContext.Provider value={this.strike}>
|
||||||
{new Array(this.state.bolts).fill(null).map((_, i) => <Lightning key={i} />)}
|
{this.state.bolts}
|
||||||
{children}
|
{children}
|
||||||
</LightningContext.Provider>
|
</LightningContext.Provider>
|
||||||
)
|
)
|
||||||
@ -35,31 +40,33 @@ export class LightningProvider extends React.Component {
|
|||||||
|
|
||||||
export const LightningConsumer = LightningContext.Consumer
|
export const LightningConsumer = LightningContext.Consumer
|
||||||
export function useLightning () {
|
export function useLightning () {
|
||||||
const { strike } = useContext(LightningContext)
|
return useContext(LightningContext)
|
||||||
return strike
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Lightning () {
|
export function Lightning ({ onDone }) {
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
|
if (canvas.bolt) return
|
||||||
|
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
canvas.width = window.innerWidth
|
canvas.width = window.innerWidth
|
||||||
canvas.height = window.innerHeight
|
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],
|
startPoint: [Math.random() * (canvas.width * 0.5) + (canvas.width * 0.25), 0],
|
||||||
length: canvas.height,
|
length: canvas.height,
|
||||||
speed: 100,
|
speed: 100,
|
||||||
spread: 30,
|
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) {
|
function Bolt (ctx, options) {
|
||||||
@ -79,12 +86,6 @@ function Bolt (ctx, options) {
|
|||||||
this.lastAngle = this.options.angle
|
this.lastAngle = this.options.angle
|
||||||
this.children = []
|
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.shadowColor = 'rgba(250, 218, 94, 1)'
|
||||||
ctx.shadowBlur = 5
|
ctx.shadowBlur = 5
|
||||||
ctx.shadowOffsetX = 0
|
ctx.shadowOffsetX = 0
|
||||||
@ -92,6 +93,7 @@ function Bolt (ctx, options) {
|
|||||||
ctx.fillStyle = 'rgba(250, 250, 250, 1)'
|
ctx.fillStyle = 'rgba(250, 250, 250, 1)'
|
||||||
ctx.strokeStyle = 'rgba(250, 218, 94, 1)'
|
ctx.strokeStyle = 'rgba(250, 218, 94, 1)'
|
||||||
ctx.lineWidth = this.options.lineWidth
|
ctx.lineWidth = this.options.lineWidth
|
||||||
|
|
||||||
this.draw = (isChild) => {
|
this.draw = (isChild) => {
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(this.point[0], this.point[1])
|
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)
|
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) {
|
if (randInRange(0, 99) < this.options.branches && this.children.length < this.options.maxBranches) {
|
||||||
this.children.push(new Bolt(ctx, {
|
this.children.push(new Bolt(ctx, {
|
||||||
startPoint: [this.point[0], this.point[1]],
|
startPoint: [this.point[0], this.point[1]],
|
||||||
@ -146,6 +145,7 @@ function Bolt (ctx, options) {
|
|||||||
ctx.canvas.style.opacity -= 0.04
|
ctx.canvas.style.opacity -= 0.04
|
||||||
if (ctx.canvas.style.opacity <= 0) {
|
if (ctx.canvas.style.opacity <= 0) {
|
||||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||||
|
this.options.onDone()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,9 +29,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
title
|
title
|
||||||
unshorted
|
unshorted
|
||||||
}
|
}
|
||||||
}`, {
|
}`)
|
||||||
fetchPolicy: 'network-only'
|
|
||||||
})
|
|
||||||
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
|
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
query Dupes($url: String!) {
|
query Dupes($url: String!) {
|
||||||
@ -39,7 +37,6 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
...ItemFields
|
...ItemFields
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
onCompleted: () => setPostDisabled(false)
|
onCompleted: () => setPostDisabled(false)
|
||||||
})
|
})
|
||||||
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
||||||
@ -50,9 +47,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||||||
...ItemFields
|
...ItemFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`, {
|
}`)
|
||||||
fetchPolicy: 'network-only'
|
|
||||||
})
|
|
||||||
|
|
||||||
const related = []
|
const related = []
|
||||||
for (const item of relatedData?.related?.items || []) {
|
for (const item of relatedData?.related?.items || []) {
|
||||||
|
@ -15,14 +15,15 @@ export default function LoginButton ({ text, type, className, onClick }) {
|
|||||||
Icon = GithubIcon
|
Icon = GithubIcon
|
||||||
variant = 'dark'
|
variant = 'dark'
|
||||||
break
|
break
|
||||||
case 'lightning':
|
|
||||||
Icon = LightningIcon
|
|
||||||
variant = 'primary'
|
|
||||||
break
|
|
||||||
case 'slashtags':
|
case 'slashtags':
|
||||||
Icon = SlashtagsIcon
|
Icon = SlashtagsIcon
|
||||||
variant = 'grey-medium'
|
variant = 'grey-medium'
|
||||||
break
|
break
|
||||||
|
case 'lightning':
|
||||||
|
default:
|
||||||
|
Icon = LightningIcon
|
||||||
|
variant = 'primary'
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
|
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
|
||||||
|
@ -7,7 +7,7 @@ export const MeContext = React.createContext({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function MeProvider ({ me, children }) {
|
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 = {
|
const contextValue = {
|
||||||
me: data?.me || me
|
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 { Button } from 'react-bootstrap'
|
||||||
import { useState } from 'react'
|
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)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -28,9 +29,24 @@ export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText })
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Footer = () => (
|
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>
|
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 { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useApolloClient, useQuery } from '@apollo/client'
|
||||||
import Comment, { CommentSkeleton } from './comment'
|
import Comment, { CommentSkeleton } from './comment'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import { NOTIFICATIONS } from '../fragments/notifications'
|
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import MoreFooter from './more-footer'
|
import MoreFooter from './more-footer'
|
||||||
import Invite from './invite'
|
import Invite from './invite'
|
||||||
import { ignoreClick } from '../lib/clicks'
|
import { ignoreClick } from '../lib/clicks'
|
||||||
@ -20,6 +19,7 @@ import { Alert } from 'react-bootstrap'
|
|||||||
import styles from './notifications.module.css'
|
import styles from './notifications.module.css'
|
||||||
import { useServiceWorker } from './serviceworker'
|
import { useServiceWorker } from './serviceworker'
|
||||||
import { Checkbox, Form } from './form'
|
import { Checkbox, Form } from './form'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
function Notification ({ n }) {
|
function Notification ({ n }) {
|
||||||
switch (n.__typename) {
|
switch (n.__typename) {
|
||||||
@ -37,39 +37,47 @@ function Notification ({ n }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationLayout ({ children, onClick }) {
|
function NotificationLayout ({ children, href, as }) {
|
||||||
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='clickToContext' onClick={(e) => {
|
className='clickToContext'
|
||||||
if (ignoreClick(e)) return
|
onClick={(e) => !ignoreClick(e) && router.push(href, as)}
|
||||||
onClick?.(e)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOnClick = (n, router) => () => {
|
const defaultOnClick = n => {
|
||||||
if (!n.item.title) {
|
if (!n.item.title) {
|
||||||
const path = n.item.path.split('.')
|
const path = n.item.path.split('.')
|
||||||
if (path.length > COMMENT_DEPTH_LIMIT + 1) {
|
if (path.length > COMMENT_DEPTH_LIMIT + 1) {
|
||||||
const rootId = path.slice(-(COMMENT_DEPTH_LIMIT + 1))[0]
|
const rootId = path.slice(-(COMMENT_DEPTH_LIMIT + 1))[0]
|
||||||
router.push({
|
return {
|
||||||
|
href: {
|
||||||
pathname: '/items/[id]',
|
pathname: '/items/[id]',
|
||||||
query: { id: rootId, commentId: n.item.id }
|
query: { id: rootId, commentId: n.item.id }
|
||||||
}, `/items/${rootId}`)
|
},
|
||||||
} else {
|
as: `/items/${rootId}`
|
||||||
router.push({
|
|
||||||
pathname: '/items/[id]',
|
|
||||||
query: { id: n.item.root.id, commentId: n.item.id }
|
|
||||||
}, `/items/${n.item.root.id}`)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
router.push({
|
return {
|
||||||
|
href: {
|
||||||
|
pathname: '/items/[id]',
|
||||||
|
query: { id: n.item.root.id, commentId: n.item.id }
|
||||||
|
},
|
||||||
|
as: `/items/${n.item.root.id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
href: {
|
||||||
pathname: '/items/[id]',
|
pathname: '/items/[id]',
|
||||||
query: { id: n.item.id }
|
query: { id: n.item.id }
|
||||||
}, `/items/${n.item.id}`)
|
},
|
||||||
|
as: `/items/${n.item.id}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,8 +122,7 @@ function Streak ({ n }) {
|
|||||||
|
|
||||||
function EarnNotification ({ n }) {
|
function EarnNotification ({ n }) {
|
||||||
return (
|
return (
|
||||||
<NotificationLayout>
|
<div className='d-flex ml-2 py-1'>
|
||||||
<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)' }} />
|
<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='ml-2'>
|
||||||
<div className='font-weight-bold text-boost'>
|
<div className='font-weight-bold text-boost'>
|
||||||
@ -129,18 +136,16 @@ function EarnNotification ({ n }) {
|
|||||||
{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>}
|
{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>}
|
||||||
<div className='pb-1' style={{ lineHeight: '140%' }}>
|
<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>.
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NotificationLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invitification ({ n }) {
|
function Invitification ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout onClick={() => router.push('/invites')}>
|
<NotificationLayout href='/invites'>
|
||||||
<small className='font-weight-bold text-secondary ml-2'>
|
<small className='font-weight-bold text-secondary ml-2'>
|
||||||
your invite has been redeemed by {n.invite.invitees.length} stackers
|
your invite has been redeemed by {n.invite.invitees.length} stackers
|
||||||
</small>
|
</small>
|
||||||
@ -157,9 +162,8 @@ function Invitification ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InvoicePaid ({ n }) {
|
function InvoicePaid ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
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'>
|
<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
|
<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>
|
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||||
@ -172,7 +176,7 @@ function Referral ({ n }) {
|
|||||||
return (
|
return (
|
||||||
<NotificationLayout>
|
<NotificationLayout>
|
||||||
<small className='font-weight-bold text-secondary ml-2'>
|
<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 className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</small>
|
</small>
|
||||||
</NotificationLayout>
|
</NotificationLayout>
|
||||||
@ -180,9 +184,8 @@ function Referral ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Votification ({ n }) {
|
function Votification ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
<NotificationLayout {...defaultOnClick(n)}>
|
||||||
<small className='font-weight-bold text-success ml-2'>
|
<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}`}
|
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
||||||
</small>
|
</small>
|
||||||
@ -202,9 +205,8 @@ function Votification ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Mention ({ n }) {
|
function Mention ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
<NotificationLayout {...defaultOnClick(n)}>
|
||||||
<small className='font-weight-bold text-info ml-2'>
|
<small className='font-weight-bold text-info ml-2'>
|
||||||
you were mentioned in
|
you were mentioned in
|
||||||
</small>
|
</small>
|
||||||
@ -223,9 +225,8 @@ function Mention ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function JobChanged ({ n }) {
|
function JobChanged ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout onClick={defaultOnClick(n, router)}>
|
<NotificationLayout {...defaultOnClick(n)}>
|
||||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||||
{n.item.status === 'ACTIVE'
|
{n.item.status === 'ACTIVE'
|
||||||
? 'your job is active again'
|
? 'your job is active again'
|
||||||
@ -239,9 +240,8 @@ function JobChanged ({ n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Reply ({ n }) {
|
function Reply ({ n }) {
|
||||||
const router = useRouter()
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout onClick={defaultOnClick(n, router)} rootText='replying on:'>
|
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
|
||||||
<div className='py-2'>
|
<div className='py-2'>
|
||||||
{n.item.title
|
{n.item.title
|
||||||
? <Item item={n.item} />
|
? <Item item={n.item} />
|
||||||
@ -274,8 +274,6 @@ function NotificationAlert () {
|
|||||||
}
|
}
|
||||||
}, [sw])
|
}, [sw])
|
||||||
|
|
||||||
if (!supported) return null
|
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
localStorage.setItem('hideNotifyPrompt', 'yep')
|
localStorage.setItem('hideNotifyPrompt', 'yep')
|
||||||
setShowAlert(false)
|
setShowAlert(false)
|
||||||
@ -305,7 +303,7 @@ function NotificationAlert () {
|
|||||||
</Alert>
|
</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
|
<Checkbox
|
||||||
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
|
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
|
||||||
groupClassName={`${styles.subFormGroup} mb-1 mr-sm-3 mr-0`}
|
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 }) {
|
export default function Notifications ({ ssrData }) {
|
||||||
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
|
const { data, fetchMore } = useQuery(NOTIFICATIONS)
|
||||||
|
const client = useApolloClient()
|
||||||
|
|
||||||
if (data) {
|
useEffect(() => {
|
||||||
({ notifications: { notifications, earn, cursor } } = data)
|
client.writeQuery({
|
||||||
|
query: HAS_NOTIFICATIONS,
|
||||||
|
data: {
|
||||||
|
hasNewNotes: false
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}, [client])
|
||||||
|
|
||||||
const [fresh, old] =
|
const { notifications: { notifications, earn, lastChecked, cursor } } = useMemo(() => {
|
||||||
notifications.reduce((result, n) => {
|
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)
|
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
[[], []])
|
[[], []])
|
||||||
|
}, [notifications, lastChecked])
|
||||||
|
|
||||||
|
if (!data && !ssrData) return <CommentsFlatSkeleton />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NotificationAlert />
|
<NotificationAlert />
|
||||||
{/* XXX we shouldn't use the index but we don't have a unique id in this union yet */}
|
|
||||||
<div className='fresh'>
|
<div className='fresh'>
|
||||||
{earn && <Notification n={earn} key='earn' />}
|
{earn && <Notification n={earn} key='earn' />}
|
||||||
{fresh.map((n, i) => (
|
{fresh.map((n, i) => (
|
||||||
<Notification n={n} key={i} />
|
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{old.map((n, i) => (
|
{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,7 +366,8 @@ function CommentsFlatSkeleton () {
|
|||||||
const comments = new Array(21).fill(null)
|
const comments = new Array(21).fill(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>{comments.map((_, i) => (
|
<div>
|
||||||
|
{comments.map((_, i) => (
|
||||||
<CommentSkeleton key={i} skeletonChildren={0} />
|
<CommentSkeleton key={i} skeletonChildren={0} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
9
components/page-loading.js
Normal file
9
components/page-loading.js
Normal file
@ -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 AccordianItem from './accordian-item'
|
||||||
import Item, { ItemSkeleton } from './item'
|
import Items from './items'
|
||||||
import { BOUNTY_ITEMS_BY_USER_NAME } from '../fragments/items'
|
import { NavigateFooter } from './more-footer'
|
||||||
import Link from 'next/link'
|
|
||||||
import styles from './items.module.css'
|
|
||||||
|
|
||||||
export default function PastBounties ({ children, item }) {
|
const LIMIT = 5
|
||||||
const emptyItems = new Array(5).fill(null)
|
|
||||||
|
|
||||||
const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, {
|
export default function PastBounties ({ item }) {
|
||||||
variables: {
|
const variables = {
|
||||||
name: item.user.name,
|
name: item.user.name,
|
||||||
limit: 5
|
sort: 'user',
|
||||||
},
|
type: 'bounties',
|
||||||
fetchPolicy: 'cache-first'
|
limit: LIMIT
|
||||||
})
|
|
||||||
|
|
||||||
let items, cursor
|
|
||||||
if (data) {
|
|
||||||
({ getBountiesByUserName: { items, cursor } } = data)
|
|
||||||
items = items.filter(i => i.id !== item.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
|
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
|
||||||
body={
|
body={
|
||||||
<>
|
<Items
|
||||||
<div className={styles.grid}>
|
variables={variables}
|
||||||
{loading
|
Footer={props => <NavigateFooter {...props} href={`/${item.user.name}/bounties`} text='view all past bounties' />}
|
||||||
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
|
filter={i => i.id !== item.id}
|
||||||
: (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>}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,6 @@ import React from 'react'
|
|||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import styles from './pay-bounty.module.css'
|
import styles from './pay-bounty.module.css'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import ModalButton from './modal-button'
|
|
||||||
import { useMutation, gql } from '@apollo/client'
|
import { useMutation, gql } from '@apollo/client'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
@ -61,7 +60,7 @@ export default function PayBounty ({ children, item }) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePayBounty = async () => {
|
const handlePayBounty = async onComplete => {
|
||||||
try {
|
try {
|
||||||
await act({
|
await act({
|
||||||
variables: { id: item.id, sats: root.bounty },
|
variables: { id: item.id, sats: root.bounty },
|
||||||
@ -72,6 +71,7 @@ export default function PayBounty ({ children, item }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onComplete()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.toString().includes('insufficient funds')) {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
@ -92,22 +92,24 @@ export default function PayBounty ({ children, item }) {
|
|||||||
notForm
|
notForm
|
||||||
overlayText={`${root.bounty} sats`}
|
overlayText={`${root.bounty} sats`}
|
||||||
>
|
>
|
||||||
<ModalButton
|
<div
|
||||||
clicker={
|
className={styles.pay} onClick={() => {
|
||||||
<div className={styles.pay}>
|
showModal(onClose => (
|
||||||
pay bounty
|
<>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className='text-center font-weight-bold text-muted'>
|
<div className='text-center font-weight-bold text-muted'>
|
||||||
Pay this bounty to {item.user.name}?
|
Pay this bounty to {item.user.name}?
|
||||||
</div>
|
</div>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
|
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty(onClose)}>
|
||||||
pay <small>{abbrNum(root.bounty)} sats</small>
|
pay <small>{abbrNum(root.bounty)} sats</small>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModalButton>
|
</>
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
pay bounty
|
||||||
|
</div>
|
||||||
</ActionTooltip>
|
</ActionTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
|
|||||||
import Countdown from './countdown'
|
import Countdown from './countdown'
|
||||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||||
import { MAX_POLL_NUM_CHOICES } from '../lib/constants'
|
import { MAX_POLL_NUM_CHOICES } from '../lib/constants'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import FeeButton, { EditFeeButton } from './fee-button'
|
import FeeButton, { EditFeeButton } from './fee-button'
|
||||||
import Delete from './delete'
|
import Delete from './delete'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
@ -74,7 +73,6 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||||||
topLevel
|
topLevel
|
||||||
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
label={<>text <small className='text-muted ml-2'>optional</small></>}
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
<VariableInput
|
<VariableInput
|
||||||
|
@ -17,7 +17,11 @@ export function usePrice () {
|
|||||||
export function PriceProvider ({ price, children }) {
|
export function PriceProvider ({ price, children }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const fiatCurrency = me?.fiatCurrency
|
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 = {
|
const contextValue = {
|
||||||
price: data?.price || price,
|
price: data?.price || price,
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
|
import { ITEM_TYPES } from '../lib/constants'
|
||||||
import { Form, Select } from './form'
|
import { Form, Select } from './form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export default function RecentHeader ({ type, sub }) {
|
export default function RecentHeader ({ type, sub }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
const prefix = sub ? `/~${sub}` : ''
|
||||||
|
|
||||||
const items = ['posts', 'bounties', 'comments', 'links', 'discussions', 'polls']
|
const items = ITEM_TYPES(sub)
|
||||||
if (!sub?.name) {
|
|
||||||
items.push('bios')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
@ -1,37 +1,23 @@
|
|||||||
import { useQuery } from '@apollo/client'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { RELATED_ITEMS } from '../fragments/items'
|
import { RELATED_ITEMS } from '../fragments/items'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Item, { ItemSkeleton } from './item'
|
import Items from './items'
|
||||||
import styles from './items.module.css'
|
import { NavigateFooter } from './more-footer'
|
||||||
|
|
||||||
|
const LIMIT = 5
|
||||||
|
|
||||||
export default function Related ({ title, itemId }) {
|
export default function Related ({ title, itemId }) {
|
||||||
const emptyItems = new Array(5).fill(null)
|
const variables = { title, id: itemId, limit: LIMIT }
|
||||||
const { data, loading } = useQuery(RELATED_ITEMS, {
|
|
||||||
fetchPolicy: 'cache-first',
|
|
||||||
variables: { title, id: itemId, limit: 5 }
|
|
||||||
})
|
|
||||||
|
|
||||||
let items, cursor
|
|
||||||
if (data) {
|
|
||||||
({ related: { items, cursor } } = data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header={<div className='font-weight-bold'>related</div>}
|
header={<div className='font-weight-bold'>related</div>}
|
||||||
body={
|
body={
|
||||||
<>
|
<Items
|
||||||
<div className={styles.grid}>
|
query={RELATED_ITEMS}
|
||||||
{loading
|
variables={variables}
|
||||||
? emptyItems.map((_, i) => <ItemSkeleton key={i} />)
|
destructureData={data => data.related}
|
||||||
: (items?.length
|
Footer={props => <NavigateFooter {...props} href={`/items/${itemId}/related`} text='view all related items' />}
|
||||||
? 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>}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,6 @@ import { gql, useMutation } from '@apollo/client'
|
|||||||
import styles from './reply.module.css'
|
import styles from './reply.module.css'
|
||||||
import { COMMENTS } from '../fragments/comments'
|
import { COMMENTS } from '../fragments/comments'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import FeeButton from './fee-button'
|
import FeeButton from './fee-button'
|
||||||
@ -13,8 +12,8 @@ import Info from './info'
|
|||||||
|
|
||||||
export function ReplyOnAnotherPage ({ parentId }) {
|
export function ReplyOnAnotherPage ({ parentId }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/items/${parentId}`}>
|
<Link href={`/items/${parentId}`} className={`${styles.replyButtons} text-muted`}>
|
||||||
<a className={`${styles.replyButtons} text-muted`}>reply on another page</a>
|
reply on another page
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -110,7 +109,8 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
{/* HACK if we need more items, we should probably do a comment toolbar */}
|
{/* HACK if we need more items, we should probably do a comment toolbar */}
|
||||||
{children}
|
{children}
|
||||||
</div>)}
|
</div>)}
|
||||||
<div className={reply ? `${styles.reply}` : 'd-none'}>
|
{reply &&
|
||||||
|
<div className={styles.reply}>
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
text: ''
|
text: ''
|
||||||
@ -128,7 +128,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
>
|
>
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
name='text'
|
name='text'
|
||||||
as={TextareaAutosize}
|
|
||||||
minRows={6}
|
minRows={6}
|
||||||
autoFocus={!replyOpen}
|
autoFocus={!replyOpen}
|
||||||
required
|
required
|
||||||
@ -144,7 +143,7 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
|
|||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>}
|
||||||
</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({
|
await router.push({
|
||||||
pathname: '/stackers/search',
|
pathname: '/stackers/search',
|
||||||
query: { q, what: 'stackers' }
|
query: { q, what: 'stackers' }
|
||||||
|
}, {
|
||||||
|
pathname: '/stackers/search',
|
||||||
|
query: { q }
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -50,6 +53,7 @@ export default function Search ({ sub }) {
|
|||||||
|
|
||||||
const showSearch = atBottom || searching || router.query.q
|
const showSearch = atBottom || searching || router.query.q
|
||||||
const filter = sub !== 'jobs'
|
const filter = sub !== 'jobs'
|
||||||
|
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
|
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
|
||||||
@ -60,7 +64,7 @@ export default function Search ({ sub }) {
|
|||||||
className={styles.formActive}
|
className={styles.formActive}
|
||||||
initial={{
|
initial={{
|
||||||
q: router.query.q || '',
|
q: router.query.q || '',
|
||||||
what: router.query.what || '',
|
what: what || '',
|
||||||
sort: router.query.sort || '',
|
sort: router.query.sort || '',
|
||||||
when: router.query.when || ''
|
when: router.query.when || ''
|
||||||
}}
|
}}
|
||||||
@ -75,7 +79,7 @@ export default function Search ({ sub }) {
|
|||||||
size='sm'
|
size='sm'
|
||||||
items={['all', 'posts', 'comments', 'stackers']}
|
items={['all', 'posts', 'comments', 'stackers']}
|
||||||
/>
|
/>
|
||||||
{router.query.what !== 'stackers' &&
|
{what !== 'stackers' &&
|
||||||
<>
|
<>
|
||||||
by
|
by
|
||||||
<Select
|
<Select
|
||||||
|
@ -68,7 +68,7 @@ export default function Seo ({ sub, item, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (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 (
|
return (
|
||||||
|
@ -2,8 +2,9 @@ import { Alert } from 'react-bootstrap'
|
|||||||
import YouTube from '../svgs/youtube-line.svg'
|
import YouTube from '../svgs/youtube-line.svg'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { gql, useQuery } from '@apollo/client'
|
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 [show, setShow] = useState()
|
||||||
const { data } = useQuery(gql`{ snl }`, {
|
const { data } = useQuery(gql`{ snl }`, {
|
||||||
fetchPolicy: 'cache-and-network'
|
fetchPolicy: 'cache-and-network'
|
||||||
@ -11,14 +12,12 @@ export default function Snl () {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dismissed = localStorage.getItem('snl')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.snl) {
|
setShow(data?.snl)
|
||||||
setShow(true)
|
}, [data, ignorePreference])
|
||||||
}
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
if (!show) return null
|
if (!show) return null
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import { gql } from 'apollo-server-micro'
|
import { gql } from 'graphql-tag'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
|
||||||
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
|
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
|
||||||
|
@ -40,7 +40,7 @@ export default function Toc ({ text }) {
|
|||||||
style={{
|
style={{
|
||||||
marginLeft: `${(v.depth - 1) * 5}px`
|
marginLeft: `${(v.depth - 1) * 5}px`
|
||||||
}}
|
}}
|
||||||
key={v.slug} href={`#${v.slug}`}
|
href={`#${v.slug}`} key={v.slug}
|
||||||
>{v.heading}
|
>{v.heading}
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
)
|
)
|
||||||
|
@ -8,7 +8,7 @@ import sub from '../lib/remark-sub'
|
|||||||
import remarkDirective from 'remark-directive'
|
import remarkDirective from 'remark-directive'
|
||||||
import { visit } from 'unist-util-visit'
|
import { visit } from 'unist-util-visit'
|
||||||
import reactStringReplace from 'react-string-replace'
|
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 GithubSlugger from 'github-slugger'
|
||||||
import LinkIcon from '../svgs/link.svg'
|
import LinkIcon from '../svgs/link.svg'
|
||||||
import Thumb from '../svgs/thumb-up-fill.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 copy from 'clipboard-copy'
|
||||||
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
|
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
|
||||||
import { extractUrls } from '../lib/md'
|
import { extractUrls } from '../lib/md'
|
||||||
|
import FileMissing from '../svgs/file-warning-line.svg'
|
||||||
|
|
||||||
function myRemarkPlugin () {
|
function myRemarkPlugin () {
|
||||||
return (tree) => {
|
return (tree) => {
|
||||||
@ -41,7 +42,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
|
|||||||
const Icon = copied ? Thumb : LinkIcon
|
const Icon = copied ? Thumb : LinkIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.heading}>
|
<span className={styles.heading}>
|
||||||
{React.createElement(h, { id, ...props }, children)}
|
{React.createElement(h, { id, ...props }, children)}
|
||||||
{!noFragments && topLevel &&
|
{!noFragments && topLevel &&
|
||||||
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
<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'
|
className='fill-grey'
|
||||||
/>
|
/>
|
||||||
</a>}
|
</a>}
|
||||||
</div>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +69,8 @@ const CACHE_STATES = {
|
|||||||
IS_ERROR: 'IS_ERROR'
|
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
|
// all the reactStringReplace calls are to facilitate search highlighting
|
||||||
const slugger = new GithubSlugger()
|
const slugger = new GithubSlugger()
|
||||||
onlyImgProxy = onlyImgProxy ?? true
|
onlyImgProxy = onlyImgProxy ?? true
|
||||||
@ -121,9 +123,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
|
|||||||
h5: (props) => HeadingWrapper({ h: topLevel ? 'h5' : 'h6', ...props }),
|
h5: (props) => HeadingWrapper({ h: topLevel ? 'h5' : 'h6', ...props }),
|
||||||
h6: (props) => HeadingWrapper({ h: 'h6', ...props }),
|
h6: (props) => HeadingWrapper({ h: 'h6', ...props }),
|
||||||
table: ({ node, ...props }) =>
|
table: ({ node, ...props }) =>
|
||||||
<div className='table-responsive'>
|
<span className='table-responsive'>
|
||||||
<table className='table table-bordered table-sm' {...props} />
|
<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 }) {
|
code ({ node, inline, className, children, ...props }) {
|
||||||
const match = /language-(\w+)/.exec(className || '')
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
return !inline
|
return !inline
|
||||||
@ -179,13 +182,10 @@ export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, c
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export function ZoomableImage ({ src, topLevel, ...props }) {
|
export function ZoomableImage ({ src, topLevel, ...props }) {
|
||||||
if (!src) {
|
const [err, setErr] = useState()
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultMediaStyle = {
|
const defaultMediaStyle = {
|
||||||
maxHeight: topLevel ? '75vh' : '25vh',
|
maxHeight: topLevel ? '75vh' : '25vh',
|
||||||
cursor: 'zoom-in'
|
cursor: 'zoom-in'
|
||||||
@ -195,9 +195,25 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
|
|||||||
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
|
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMediaStyle(defaultMediaStyle)
|
setMediaStyle(defaultMediaStyle)
|
||||||
|
setErr(null)
|
||||||
}, [src])
|
}, [src])
|
||||||
|
|
||||||
const handleClick = () => {
|
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 (
|
||||||
|
<img
|
||||||
|
className={topLevel ? styles.topLevel : undefined}
|
||||||
|
style={mediaStyle}
|
||||||
|
src={src}
|
||||||
|
onClick={() => {
|
||||||
if (mediaStyle.cursor === 'zoom-in') {
|
if (mediaStyle.cursor === 'zoom-in') {
|
||||||
setMediaStyle({
|
setMediaStyle({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -206,14 +222,8 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
|
|||||||
} else {
|
} else {
|
||||||
setMediaStyle(defaultMediaStyle)
|
setMediaStyle(defaultMediaStyle)
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
|
onError={() => setErr(true)}
|
||||||
return (
|
|
||||||
<img
|
|
||||||
className={topLevel ? styles.topLevel : undefined}
|
|
||||||
style={mediaStyle}
|
|
||||||
src={src}
|
|
||||||
onClick={handleClick}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
margin-left: -22px;
|
margin-left: -22px;
|
||||||
padding-left: 22px;
|
padding-left: 22px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headingLink {
|
.headingLink {
|
||||||
@ -23,6 +24,7 @@
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headingLink.copied {
|
.headingLink.copied {
|
||||||
@ -41,7 +43,7 @@
|
|||||||
border-top: 1px solid var(--theme-clickToContextColor);
|
border-top: 1px solid var(--theme-clickToContextColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text p {
|
.text .p {
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Form, Select } from './form'
|
import { Form, Select } from './form'
|
||||||
|
import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants'
|
||||||
const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
|
|
||||||
const ITEM_SORTS = ['votes', 'comments', 'sats']
|
|
||||||
|
|
||||||
export default function TopHeader ({ sub, cat }) {
|
export default function TopHeader ({ sub, cat }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -19,11 +17,11 @@ export default function TopHeader ({ sub, cat }) {
|
|||||||
|
|
||||||
const prefix = sub ? `/~${sub}` : ''
|
const prefix = sub ? `/~${sub}` : ''
|
||||||
|
|
||||||
if (typeof query.sort !== 'undefined') {
|
if (typeof query.by !== 'undefined') {
|
||||||
if (query.sort === '' ||
|
if (query.by === '' ||
|
||||||
(what === 'stackers' && !USER_SORTS.includes(query.sort)) ||
|
(what === 'stackers' && !USER_SORTS.includes(query.by)) ||
|
||||||
(what !== 'stackers' && !ITEM_SORTS.includes(query.sort))) {
|
(what !== 'stackers' && !ITEM_SORTS.includes(query.by))) {
|
||||||
delete query.sort
|
delete query.by
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +37,7 @@ export default function TopHeader ({ sub, cat }) {
|
|||||||
className='mr-auto'
|
className='mr-auto'
|
||||||
initial={{
|
initial={{
|
||||||
what: cat,
|
what: cat,
|
||||||
sort: router.query.sort || '',
|
by: router.query.by || '',
|
||||||
when: router.query.when || ''
|
when: router.query.when || ''
|
||||||
}}
|
}}
|
||||||
onSubmit={top}
|
onSubmit={top}
|
||||||
@ -58,8 +56,8 @@ export default function TopHeader ({ sub, cat }) {
|
|||||||
by
|
by
|
||||||
<Select
|
<Select
|
||||||
groupClassName='mx-2 mb-0'
|
groupClassName='mx-2 mb-0'
|
||||||
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
|
onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })}
|
||||||
name='sort'
|
name='by'
|
||||||
size='sm'
|
size='sm'
|
||||||
items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS}
|
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 })}
|
onChange={(formik, e) => top({ ...formik?.values, when: e.target.value })}
|
||||||
name='when'
|
name='when'
|
||||||
size='sm'
|
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 ItemAct from './item-act'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import Rainbow from '../lib/rainbow'
|
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 LongPressable from 'react-longpressable'
|
||||||
import { Overlay, Popover } from 'react-bootstrap'
|
import { Overlay, Popover } from 'react-bootstrap'
|
||||||
import { useShowModal } from './modal'
|
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 (!me) return
|
||||||
|
|
||||||
// if they haven't seen the walkthrough and they have sats
|
// 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)
|
_setVoteShow(false)
|
||||||
setWalkthrough({ variables: { upvotePopover: true } })
|
setWalkthrough({ variables: { upvotePopover: true } })
|
||||||
}
|
}
|
||||||
}
|
}, [me, voteShow, setWalkthrough])
|
||||||
|
|
||||||
const setTipShow = (yes) => {
|
const setTipShow = useCallback((yes) => {
|
||||||
if (!me) return
|
if (!me) return
|
||||||
|
|
||||||
// if we want to show it, yet we still haven't shown
|
// 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)
|
_setTipShow(false)
|
||||||
setWalkthrough({ variables: { tipPopover: true } })
|
setWalkthrough({ variables: { tipPopover: true } })
|
||||||
}
|
}
|
||||||
}
|
}, [me, tipShow, setWalkthrough])
|
||||||
|
|
||||||
const [act] = useMutation(
|
const [act] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
@ -161,18 +161,20 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pendingSats > 0) {
|
if (pendingSats > 0) {
|
||||||
timerRef.current = setTimeout(async (pendingSats) => {
|
timerRef.current = setTimeout(async (sats) => {
|
||||||
try {
|
try {
|
||||||
timerRef.current && setPendingSats(0)
|
timerRef.current && setPendingSats(0)
|
||||||
await act({
|
await act({
|
||||||
variables: { id: item.id, sats: pendingSats },
|
variables: { id: item.id, sats },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
act: {
|
act: {
|
||||||
sats: pendingSats
|
sats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!timerRef.current) return
|
||||||
|
|
||||||
if (error.toString().includes('insufficient funds')) {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
showModal(onClose => {
|
showModal(onClose => {
|
||||||
return <FundError onClose={onClose} />
|
return <FundError onClose={onClose} />
|
||||||
@ -181,14 +183,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
}
|
}
|
||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
}
|
}
|
||||||
}, 1000, pendingSats)
|
}, 500, pendingSats)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return async () => {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
timerRef.current = null
|
timerRef.current = null
|
||||||
}
|
}
|
||||||
}, [item, pendingSats, act, setPendingSats, showModal])
|
}, [pendingSats, act, item, showModal, setPendingSats])
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
const disabled = useMemo(() => {
|
||||||
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
|
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
|
||||||
@ -213,7 +215,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LightningConsumer>
|
<LightningConsumer>
|
||||||
{({ strike }) =>
|
{(strike) =>
|
||||||
<div ref={ref} className='upvoteParent'>
|
<div ref={ref} className='upvoteParent'>
|
||||||
<LongPressable
|
<LongPressable
|
||||||
onLongPress={
|
onLongPress={
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Form, Select } from './form'
|
import { Form, Select } from './form'
|
||||||
|
import { WHENS } from '../lib/constants'
|
||||||
|
|
||||||
export function UsageHeader () {
|
export function UsageHeader () {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -17,7 +18,7 @@ export function UsageHeader () {
|
|||||||
className='w-auto'
|
className='w-auto'
|
||||||
name='when'
|
name='when'
|
||||||
size='sm'
|
size='sm'
|
||||||
items={['day', 'week', 'month', 'year', 'forever']}
|
items={WHENS}
|
||||||
onChange={(formik, e) => router.push(`/stackers/${e.target.value}`)}
|
onChange={(formik, e) => router.push(`/stackers/${e.target.value}`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,20 +10,38 @@ import { useMe } from './me'
|
|||||||
import { NAME_MUTATION } from '../fragments/users'
|
import { NAME_MUTATION } from '../fragments/users'
|
||||||
import QRCode from 'qrcode.react'
|
import QRCode from 'qrcode.react'
|
||||||
import LightningIcon from '../svgs/bolt.svg'
|
import LightningIcon from '../svgs/bolt.svg'
|
||||||
import ModalButton from './modal-button'
|
|
||||||
import { encodeLNUrl } from '../lib/lnurl'
|
import { encodeLNUrl } from '../lib/lnurl'
|
||||||
import Avatar from './avatar'
|
import Avatar from './avatar'
|
||||||
import CowboyHat from './cowboy-hat'
|
import CowboyHat from './cowboy-hat'
|
||||||
import { userSchema } from '../lib/validate'
|
import { userSchema } from '../lib/validate'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export default function UserHeader ({ user }) {
|
export default function UserHeader ({ user }) {
|
||||||
const [editting, setEditting] = useState(false)
|
|
||||||
const me = useMe()
|
|
||||||
const router = useRouter()
|
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(
|
const [setPhoto] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation setPhoto($photoId: ID!) {
|
mutation setPhoto($photoId: ID!) {
|
||||||
@ -42,14 +60,7 @@ 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 (
|
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' }}>
|
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
||||||
<Image
|
<Image
|
||||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
||||||
@ -64,9 +75,27 @@ export default function UserHeader ({ user }) {
|
|||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
|
)
|
||||||
{editting
|
}
|
||||||
? (
|
|
||||||
|
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
|
<Form
|
||||||
schema={schema}
|
schema={schema}
|
||||||
initial={{
|
initial={{
|
||||||
@ -83,25 +112,13 @@ export default function UserHeader ({ user }) {
|
|||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEditting(false)
|
||||||
|
// navigate to new name
|
||||||
const { nodata, ...query } = router.query
|
const { nodata, ...query } = router.query
|
||||||
router.replace({
|
router.replace({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: { ...query, name }
|
query: { ...query, name }
|
||||||
})
|
}, undefined, { shallow: true })
|
||||||
|
|
||||||
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'>
|
<div className='d-flex align-items-center mb-2'>
|
||||||
@ -116,64 +133,66 @@ export default function UserHeader ({ user }) {
|
|||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
: (
|
}
|
||||||
|
|
||||||
|
function NymView ({ user, isMe, setEditting }) {
|
||||||
|
return (
|
||||||
<div className='d-flex align-items-center mb-2'>
|
<div className='d-flex align-items-center mb-2'>
|
||||||
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
|
<div className={styles.username}>@{user.name}<CowboyHat className='' user={user} badge /></div>
|
||||||
{isMe &&
|
{isMe &&
|
||||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||||
</div>
|
</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} />
|
<Satistics user={user} />
|
||||||
<ModalButton
|
<Button
|
||||||
clicker={
|
className='font-weight-bold ml-0' onClick={() => {
|
||||||
<Button className='font-weight-bold ml-0'>
|
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
|
<LightningIcon
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
/>{user.name}@stacker.news
|
/>{user.name}@stacker.news
|
||||||
</Button>
|
</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'>
|
<div className='d-flex flex-column mt-1 ml-0'>
|
||||||
<small className='text-muted d-flex-inline'>stacking since: {user.since
|
<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>
|
? <Link href={`/items/${user.since}`} className='ml-1'>#{user.since}</Link>
|
||||||
: <span>never</span>}
|
: <span>never</span>}
|
||||||
</small>
|
</small>
|
||||||
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
|
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,16 +4,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
margin-top: 1rem;
|
margin: 1rem 0;
|
||||||
justify-content: space-between;
|
justify-content: start;
|
||||||
|
font-size: 110%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav div:first-child a {
|
.nav :global .nav-link {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav div:last-child a {
|
.nav :global .nav-item:not(:first-child) {
|
||||||
padding-right: 0;
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav :global .active {
|
||||||
|
border-bottom: 2px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.userimg {
|
.userimg {
|
||||||
|
@ -4,18 +4,20 @@ import { abbrNum } from '../lib/format'
|
|||||||
import CowboyHat from './cowboy-hat'
|
import CowboyHat from './cowboy-hat'
|
||||||
import styles from './item.module.css'
|
import styles from './item.module.css'
|
||||||
import userStyles from './user-header.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
|
// all of this nonsense is to show the stat we are sorting by first
|
||||||
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
|
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
|
||||||
const Spent = ({ user }) => (<span>{abbrNum(user.spent)} spent</span>)
|
const Spent = ({ user }) => (<span>{abbrNum(user.spent)} spent</span>)
|
||||||
const Posts = ({ user }) => (
|
const Posts = ({ user }) => (
|
||||||
<Link href={`/${user.name}/posts`} passHref>
|
<Link href={`/${user.name}/posts`} className='text-reset'>
|
||||||
<a className='text-reset'>{abbrNum(user.nitems)} posts</a>
|
{abbrNum(user.nposts)} posts
|
||||||
</Link>)
|
</Link>)
|
||||||
const Comments = ({ user }) => (
|
const Comments = ({ user }) => (
|
||||||
<Link href={`/${user.name}/comments`} passHref>
|
<Link href={`/${user.name}/comments`} className='text-reset'>
|
||||||
<a className='text-reset'>{abbrNum(user.ncomments)} comments</a>
|
{abbrNum(user.ncomments)} comments
|
||||||
</Link>)
|
</Link>)
|
||||||
const Referrals = ({ user }) => (<span>{abbrNum(user.referrals)} referrals</span>)
|
const Referrals = ({ user }) => (<span>{abbrNum(user.referrals)} referrals</span>)
|
||||||
const Seperator = () => (<span> \ </span>)
|
const Seperator = () => (<span> \ </span>)
|
||||||
@ -33,33 +35,44 @@ function seperate (arr, seperator) {
|
|||||||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
|
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))
|
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sort) {
|
if (variables.by) {
|
||||||
// shift the stat we are sorting by to the front
|
// shift the stat we are sorting by to the front
|
||||||
const comps = [...STAT_COMPONENTS]
|
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))
|
||||||
|
}
|
||||||
|
}, [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 />
|
||||||
}
|
}
|
||||||
}, [sort])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<> {users.map(user => (
|
<>
|
||||||
|
{users?.map(user => (
|
||||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||||
<Link href={`/${user.name}`} passHref>
|
<Link href={`/${user.name}`}>
|
||||||
<a>
|
|
||||||
<Image
|
<Image
|
||||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
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`}
|
className={`${userStyles.userimg} mr-2`}
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className={styles.hunk}>
|
<div className={styles.hunk}>
|
||||||
<Link href={`/${user.name}`} passHref>
|
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||||
<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} />
|
@{user.name}<CowboyHat className='ml-1 fill-grey' height={14} width={14} user={user} />
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className={styles.other}>
|
<div className={styles.other}>
|
||||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||||
@ -67,6 +80,7 @@ export default function UserList ({ users, sort }) {
|
|||||||
</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>{users.map((_, i) => (
|
||||||
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
|
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
|
||||||
<Image
|
<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`}
|
className={`${userStyles.userimg} clouds mr-2`}
|
||||||
/>
|
/>
|
||||||
<div className={styles.hunk}>
|
<div className={styles.hunk}>
|
||||||
|
@ -31,14 +31,9 @@ export const COMMENT_FIELDS = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const MORE_FLAT_COMMENTS = gql`
|
export const COMMENTS_ITEM_EXT_FIELDS = gql`
|
||||||
${COMMENT_FIELDS}
|
fragment CommentItemExtFields on Item {
|
||||||
|
text
|
||||||
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 {
|
root {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
@ -52,36 +47,7 @@ export const MORE_FLAT_COMMENTS = gql`
|
|||||||
id
|
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`
|
export const COMMENTS = gql`
|
||||||
${COMMENT_FIELDS}
|
${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`
|
export const POLL_FIELDS = gql`
|
||||||
fragment PollFields on Item {
|
fragment PollFields on Item {
|
||||||
poll {
|
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`
|
export const RELATED_ITEMS = gql`
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
query Related($title: String, $id: ID, $cursor: String, $limit: Int) {
|
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 { ITEM_FULL_FIELDS } from './items'
|
||||||
import { INVITE_FIELDS } from './invites'
|
import { INVITE_FIELDS } from './invites'
|
||||||
|
|
||||||
|
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
|
||||||
|
|
||||||
export const NOTIFICATIONS = gql`
|
export const NOTIFICATIONS = gql`
|
||||||
${ITEM_FULL_FIELDS}
|
${ITEM_FULL_FIELDS}
|
||||||
${INVITE_FIELDS}
|
${INVITE_FIELDS}
|
||||||
@ -13,6 +15,7 @@ export const NOTIFICATIONS = gql`
|
|||||||
notifications {
|
notifications {
|
||||||
__typename
|
__typename
|
||||||
... on Mention {
|
... on Mention {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
mention
|
mention
|
||||||
item {
|
item {
|
||||||
@ -21,6 +24,7 @@ export const NOTIFICATIONS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on Votification {
|
... on Votification {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
earnedSats
|
earnedSats
|
||||||
item {
|
item {
|
||||||
@ -34,6 +38,7 @@ export const NOTIFICATIONS = gql`
|
|||||||
days
|
days
|
||||||
}
|
}
|
||||||
... on Earn {
|
... on Earn {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
earnedSats
|
earnedSats
|
||||||
sources {
|
sources {
|
||||||
@ -44,9 +49,11 @@ export const NOTIFICATIONS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on Referral {
|
... on Referral {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
}
|
}
|
||||||
... on Reply {
|
... on Reply {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
item {
|
item {
|
||||||
...ItemFullFields
|
...ItemFullFields
|
||||||
@ -54,18 +61,21 @@ export const NOTIFICATIONS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on Invitification {
|
... on Invitification {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
invite {
|
invite {
|
||||||
...InviteFields
|
...InviteFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on JobChanged {
|
... on JobChanged {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
item {
|
item {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
... on InvoicePaid {
|
... on InvoicePaid {
|
||||||
|
id
|
||||||
sortTime
|
sortTime
|
||||||
earnedSats
|
earnedSats
|
||||||
invoice {
|
invoice {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { ITEM_FIELDS } from './items'
|
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
|
||||||
import { COMMENT_FIELDS } from './comments'
|
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
|
||||||
|
|
||||||
export const SUB_FIELDS = gql`
|
export const SUB_FIELDS = gql`
|
||||||
fragment SubFields on Sub {
|
fragment SubFields on Sub {
|
||||||
@ -13,7 +13,7 @@ export const SUB_FIELDS = gql`
|
|||||||
export const SUB = gql`
|
export const SUB = gql`
|
||||||
${SUB_FIELDS}
|
${SUB_FIELDS}
|
||||||
|
|
||||||
query Sub($sub: String!) {
|
query Sub($sub: String) {
|
||||||
sub(name: $sub) {
|
sub(name: $sub) {
|
||||||
...SubFields
|
...SubFields
|
||||||
}
|
}
|
||||||
@ -22,119 +22,43 @@ export const SUB = gql`
|
|||||||
export const SUB_ITEMS = gql`
|
export const SUB_ITEMS = gql`
|
||||||
${SUB_FIELDS}
|
${SUB_FIELDS}
|
||||||
${ITEM_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) {
|
sub(name: $sub) {
|
||||||
...SubFields
|
...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
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
|
...CommentItemExtFields @include(if: $includeComments)
|
||||||
position
|
position
|
||||||
},
|
},
|
||||||
pins {
|
pins {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
|
...CommentItemExtFields @include(if: $includeComments)
|
||||||
position
|
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`
|
export const SUB_SEARCH = gql`
|
||||||
${SUB_FIELDS}
|
${SUB_FIELDS}
|
||||||
${ITEM_FIELDS}
|
${ITEM_FULL_FIELDS}
|
||||||
query SubSearch($sub: String!, $q: String, $cursor: String) {
|
query SubSearch($sub: String, $q: String, $cursor: String, $sort: String, $what: String, $when: String) {
|
||||||
sub(name: $sub) {
|
sub(name: $sub) {
|
||||||
...SubFields
|
...SubFields
|
||||||
}
|
}
|
||||||
search(q: $q, cursor: $cursor) {
|
search(sub: $sub, q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
|
||||||
cursor
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFullFields
|
||||||
text
|
|
||||||
searchTitle
|
searchTitle
|
||||||
searchText
|
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 { gql } from '@apollo/client'
|
||||||
import { COMMENT_FIELDS } from './comments'
|
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
|
||||||
import { ITEM_FIELDS, ITEM_FULL_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||||
|
|
||||||
export const ME = gql`
|
export const ME = gql`
|
||||||
{
|
{
|
||||||
@ -116,35 +116,27 @@ gql`
|
|||||||
stacked
|
stacked
|
||||||
spent
|
spent
|
||||||
ncomments
|
ncomments
|
||||||
nitems
|
nposts
|
||||||
referrals
|
referrals
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const USER_FIELDS = gql`
|
export const USER_FIELDS = gql`
|
||||||
${ITEM_FIELDS}
|
|
||||||
fragment UserFields on User {
|
fragment UserFields on User {
|
||||||
id
|
id
|
||||||
createdAt
|
|
||||||
name
|
name
|
||||||
streak
|
streak
|
||||||
maxStreak
|
maxStreak
|
||||||
hideCowboyHat
|
hideCowboyHat
|
||||||
nitems
|
nitems
|
||||||
ncomments
|
|
||||||
nbookmarks
|
|
||||||
stacked
|
stacked
|
||||||
sats
|
since
|
||||||
photoId
|
photoId
|
||||||
bio {
|
|
||||||
...ItemFields
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const TOP_USERS = gql`
|
export const TOP_USERS = gql`
|
||||||
query TopUsers($cursor: String, $when: String, $sort: String) {
|
query TopUsers($cursor: String, $when: String, $by: String) {
|
||||||
topUsers(cursor: $cursor, when: $when, sort: $sort) {
|
topUsers(cursor: $cursor, when: $when, by: $by) {
|
||||||
users {
|
users {
|
||||||
name
|
name
|
||||||
streak
|
streak
|
||||||
@ -153,7 +145,7 @@ export const TOP_USERS = gql`
|
|||||||
stacked(when: $when)
|
stacked(when: $when)
|
||||||
spent(when: $when)
|
spent(when: $when)
|
||||||
ncomments(when: $when)
|
ncomments(when: $when)
|
||||||
nitems(when: $when)
|
nposts(when: $when)
|
||||||
referrals(when: $when)
|
referrals(when: $when)
|
||||||
}
|
}
|
||||||
cursor
|
cursor
|
||||||
@ -172,7 +164,7 @@ export const TOP_COWBOYS = gql`
|
|||||||
stacked(when: "forever")
|
stacked(when: "forever")
|
||||||
spent(when: "forever")
|
spent(when: "forever")
|
||||||
ncomments(when: "forever")
|
ncomments(when: "forever")
|
||||||
nitems(when: "forever")
|
nposts(when: "forever")
|
||||||
referrals(when: "forever")
|
referrals(when: "forever")
|
||||||
}
|
}
|
||||||
cursor
|
cursor
|
||||||
@ -186,74 +178,25 @@ export const USER_FULL = gql`
|
|||||||
query User($name: String!) {
|
query User($name: String!) {
|
||||||
user(name: $name) {
|
user(name: $name) {
|
||||||
...UserFields
|
...UserFields
|
||||||
since
|
|
||||||
bio {
|
bio {
|
||||||
...ItemWithComments
|
...ItemWithComments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const USER_WITH_COMMENTS = gql`
|
export const USER_WITH_ITEMS = 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`
|
|
||||||
${USER_FIELDS}
|
${USER_FIELDS}
|
||||||
${ITEM_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) {
|
user(name: $name) {
|
||||||
...UserFields
|
...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
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
}
|
...CommentItemExtFields @include(if: $includeComments)
|
||||||
pins {
|
|
||||||
...ItemFields
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@ -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…
x
Reference in New Issue
Block a user