Merge branch 'master' into 190-strip-tracking-info
This commit is contained in:
commit
10e5257375
9
.puppeteerrc.cjs
Normal file
9
.puppeteerrc.cjs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const {join} = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import("puppeteer").Configuration}
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
// Changes the cache location for Puppeteer.
|
||||||
|
cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
|
||||||
|
};
|
@ -1,157 +1,184 @@
|
|||||||
const PLACEHOLDERS_NUM = 616
|
const PLACEHOLDERS_NUM = 616
|
||||||
|
|
||||||
|
export function interval (when) {
|
||||||
|
switch (when) {
|
||||||
|
case 'week':
|
||||||
|
return '1 week'
|
||||||
|
case 'month':
|
||||||
|
return '1 month'
|
||||||
|
case 'year':
|
||||||
|
return '1 year'
|
||||||
|
case 'forever':
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return '1 day'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeUnit (when) {
|
||||||
|
switch (when) {
|
||||||
|
case 'week':
|
||||||
|
case 'month':
|
||||||
|
return 'day'
|
||||||
|
case 'year':
|
||||||
|
case 'forever':
|
||||||
|
return 'month'
|
||||||
|
default:
|
||||||
|
return 'hour'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withClause (when) {
|
||||||
|
const ival = interval(when)
|
||||||
|
const unit = timeUnit(when)
|
||||||
|
|
||||||
|
return `
|
||||||
|
WITH range_values AS (
|
||||||
|
SELECT date_trunc('${unit}', ${ival ? "now_utc() - interval '" + ival + "'" : "'2021-06-07'::timestamp"}) as minval,
|
||||||
|
date_trunc('${unit}', now_utc()) as maxval),
|
||||||
|
times AS (
|
||||||
|
SELECT generate_series(minval, maxval, interval '1 ${unit}') as time
|
||||||
|
FROM range_values
|
||||||
|
)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables
|
||||||
|
export function intervalClause (when, table, and) {
|
||||||
|
if (when === 'forever') {
|
||||||
|
return and ? '' : 'TRUE'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${table}".created_at >= now_utc() - interval '${interval(when)}' ${and ? 'AND' : ''} `
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
registrationGrowth: async (parent, args, { models }) => {
|
registrationGrowth: async (parent, { when }, { models }) => {
|
||||||
return await models.$queryRaw(
|
return await models.$queryRaw(
|
||||||
`SELECT date_trunc('month', created_at) AS time, count("inviteId") as invited, count(*) - count("inviteId") as organic
|
`${withClause(when)}
|
||||||
FROM users
|
SELECT time, json_build_array(
|
||||||
WHERE id > ${PLACEHOLDERS_NUM} AND date_trunc('month', now_utc()) <> date_trunc('month', created_at)
|
json_build_object('name', 'referrals', 'value', count("referrerId")),
|
||||||
|
json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId"))
|
||||||
|
) AS data
|
||||||
|
FROM times
|
||||||
|
LEFT JOIN users ON ${intervalClause(when, 'users', true)} time = date_trunc('${timeUnit(when)}', created_at)
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`)
|
ORDER BY time ASC`)
|
||||||
},
|
},
|
||||||
activeGrowth: async (parent, args, { models }) => {
|
spenderGrowth: async (parent, { when }, { models }) => {
|
||||||
return await models.$queryRaw(
|
return await models.$queryRaw(
|
||||||
`SELECT date_trunc('month', created_at) AS time, count(DISTINCT "userId") as num
|
`${withClause(when)}
|
||||||
FROM "ItemAct"
|
SELECT time, json_build_array(
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at)
|
json_build_object('name', 'any', 'value', count(DISTINCT "userId")),
|
||||||
GROUP BY time
|
json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')),
|
||||||
ORDER BY time ASC`)
|
json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')),
|
||||||
},
|
json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')),
|
||||||
itemGrowth: async (parent, args, { models }) => {
|
json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')),
|
||||||
return await models.$queryRaw(
|
json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION'))
|
||||||
`SELECT date_trunc('month', created_at) AS time, count("parentId") as comments,
|
) AS data
|
||||||
count("subName") as jobs, count(*)-count("parentId")-count("subName") as posts
|
FROM times
|
||||||
FROM "Item"
|
LEFT JOIN
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at)
|
((SELECT "ItemAct".created_at, "userId", act::text as act
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time ASC`)
|
|
||||||
},
|
|
||||||
spentGrowth: async (parent, args, { models }) => {
|
|
||||||
// add up earn for each month
|
|
||||||
// add up non-self votes/tips for posts and comments
|
|
||||||
|
|
||||||
return await models.$queryRaw(
|
|
||||||
`SELECT date_trunc('month', "ItemAct".created_at) AS time,
|
|
||||||
sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END) as jobs,
|
|
||||||
sum(CASE WHEN act IN ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END) as fees,
|
|
||||||
sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END) as boost,
|
|
||||||
sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END) as tips
|
|
||||||
FROM "ItemAct"
|
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at)
|
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time ASC`)
|
|
||||||
},
|
|
||||||
earnerGrowth: async (parent, args, { models }) => {
|
|
||||||
return await models.$queryRaw(
|
|
||||||
`SELECT time, count(distinct user_id) as num
|
|
||||||
FROM
|
|
||||||
((SELECT date_trunc('month', "ItemAct".created_at) AS time, "Item"."userId" as user_id
|
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId"
|
WHERE ${intervalClause(when, 'ItemAct', false)})
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at))
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT date_trunc('month', created_at) AS time, "userId" as user_id
|
(SELECT created_at, "userId", 'DONATION' as act
|
||||||
FROM "Earn"
|
FROM "Donation"
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at))) u
|
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||||
GROUP BY time
|
GROUP BY time
|
||||||
ORDER BY time ASC`)
|
ORDER BY time ASC`)
|
||||||
},
|
},
|
||||||
stackedGrowth: async (parent, args, { models }) => {
|
itemGrowth: async (parent, { when }, { models }) => {
|
||||||
return await models.$queryRaw(
|
return await models.$queryRaw(
|
||||||
`SELECT time, sum(airdrop) as rewards, sum(post) as posts, sum(comment) as comments
|
`${withClause(when)}
|
||||||
FROM
|
SELECT time, json_build_array(
|
||||||
((SELECT date_trunc('month', "ItemAct".created_at) AS time, 0 as airdrop,
|
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment,
|
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post
|
|
||||||
FROM "ItemAct"
|
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId"
|
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', "ItemAct".created_at) AND
|
|
||||||
"ItemAct".act IN ('VOTE', 'TIP'))
|
|
||||||
UNION ALL
|
|
||||||
(SELECT date_trunc('month', created_at) AS time, msats / 1000 as airdrop, 0 as post, 0 as comment
|
|
||||||
FROM "Earn"
|
|
||||||
WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at))) u
|
|
||||||
GROUP BY time
|
|
||||||
ORDER BY time ASC`)
|
|
||||||
},
|
|
||||||
registrationsWeekly: async (parent, args, { models }) => {
|
|
||||||
return await models.user.count({
|
|
||||||
where: {
|
|
||||||
createdAt: {
|
|
||||||
gte: new Date(new Date().setDate(new Date().getDate() - 7))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
activeWeekly: async (parent, args, { models }) => {
|
|
||||||
const [{ active }] = await models.$queryRaw(
|
|
||||||
`SELECT count(DISTINCT "userId") as active
|
|
||||||
FROM "ItemAct"
|
|
||||||
WHERE created_at >= now_utc() - interval '1 week'`
|
|
||||||
)
|
|
||||||
return active
|
|
||||||
},
|
|
||||||
earnersWeekly: async (parent, args, { models }) => {
|
|
||||||
const [{ earners }] = await models.$queryRaw(
|
|
||||||
`SELECT count(distinct user_id) as earners
|
|
||||||
FROM
|
|
||||||
((SELECT "Item"."userId" as user_id
|
|
||||||
FROM "ItemAct"
|
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId"
|
|
||||||
WHERE "ItemAct".created_at >= now_utc() - interval '1 week')
|
|
||||||
UNION ALL
|
|
||||||
(SELECT "userId" as user_id
|
|
||||||
FROM "Earn"
|
|
||||||
WHERE created_at >= now_utc() - interval '1 week')) u`)
|
|
||||||
return earners
|
|
||||||
},
|
|
||||||
itemsWeekly: async (parent, args, { models }) => {
|
|
||||||
const [stats] = await models.$queryRaw(
|
|
||||||
`SELECT json_build_array(
|
|
||||||
json_build_object('name', 'comments', 'value', count("parentId")),
|
json_build_object('name', 'comments', 'value', count("parentId")),
|
||||||
json_build_object('name', 'jobs', 'value', count("subName")),
|
json_build_object('name', 'jobs', 'value', count("subName")),
|
||||||
json_build_object('name', 'posts', 'value', count(*)-count("parentId")-count("subName"))) as array
|
json_build_object('name', 'posts', 'value', count("Item".id)-count("parentId")-count("subName"))
|
||||||
FROM "Item"
|
) AS data
|
||||||
WHERE created_at >= now_utc() - interval '1 week'`)
|
FROM times
|
||||||
|
LEFT JOIN "Item" ON ${intervalClause(when, 'Item', true)} time = date_trunc('${timeUnit(when)}', created_at)
|
||||||
return stats?.array
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`)
|
||||||
},
|
},
|
||||||
spentWeekly: async (parent, args, { models }) => {
|
spendingGrowth: async (parent, { when }, { models }) => {
|
||||||
const [stats] = await models.$queryRaw(
|
return await models.$queryRaw(
|
||||||
`SELECT json_build_array(
|
`${withClause(when)}
|
||||||
json_build_object('name', 'jobs', 'value', sum(CASE WHEN act = 'STREAM' THEN "ItemAct".sats ELSE 0 END)),
|
SELECT time, json_build_array(
|
||||||
json_build_object('name', 'fees', 'value', sum(CASE WHEN act in ('VOTE', 'POLL') AND "Item"."userId" = "ItemAct"."userId" THEN "ItemAct".sats ELSE 0 END)),
|
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'boost', 'value', sum(CASE WHEN act = 'BOOST' THEN "ItemAct".sats ELSE 0 END)),
|
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
|
||||||
json_build_object('name', 'tips', 'value', sum(CASE WHEN act = 'TIP' THEN "ItemAct".sats ELSE 0 END))) as array
|
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)),
|
||||||
FROM "ItemAct"
|
json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)),
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0))
|
||||||
WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`)
|
) AS data
|
||||||
|
FROM times
|
||||||
return stats?.array
|
LEFT JOIN
|
||||||
},
|
((SELECT "ItemAct".created_at, msats, act::text as act
|
||||||
stackedWeekly: async (parent, args, { models }) => {
|
|
||||||
const [stats] = await models.$queryRaw(
|
|
||||||
`SELECT json_build_array(
|
|
||||||
json_build_object('name', 'rewards', 'value', sum(airdrop)),
|
|
||||||
json_build_object('name', 'posts', 'value', sum(post)),
|
|
||||||
json_build_object('name', 'comments', 'value', sum(comment))
|
|
||||||
) as array
|
|
||||||
FROM
|
|
||||||
((SELECT 0 as airdrop,
|
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".sats END as comment,
|
|
||||||
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".sats ELSE 0 END as post
|
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId"
|
WHERE ${intervalClause(when, 'ItemAct', false)})
|
||||||
WHERE "ItemAct".created_at >= now_utc() - interval '1 week' AND
|
|
||||||
"ItemAct".act IN ('VOTE', 'TIP'))
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT msats / 1000 as airdrop, 0 as post, 0 as comment
|
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
|
||||||
|
FROM "Donation"
|
||||||
|
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`)
|
||||||
|
},
|
||||||
|
stackerGrowth: async (parent, { when }, { models }) => {
|
||||||
|
return await models.$queryRaw(
|
||||||
|
`${withClause(when)}
|
||||||
|
SELECT time, json_build_array(
|
||||||
|
json_build_object('name', 'any', 'value', count(distinct user_id)),
|
||||||
|
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
|
||||||
|
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
|
||||||
|
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
|
||||||
|
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL'))
|
||||||
|
) AS data
|
||||||
|
FROM times
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ItemAct".created_at, "Item"."userId" as user_id, CASE WHEN "Item"."parentId" IS NULL THEN 'POST' ELSE 'COMMENT' END as type
|
||||||
|
FROM "ItemAct"
|
||||||
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
|
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, "userId" as user_id, 'EARN' as type
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE created_at >= now_utc() - interval '1 week')) u`)
|
WHERE ${intervalClause(when, 'Earn', false)})
|
||||||
|
UNION ALL
|
||||||
return stats?.array
|
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
|
||||||
|
FROM "ReferralAct"
|
||||||
|
WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`)
|
||||||
|
},
|
||||||
|
stackingGrowth: async (parent, { when }, { models }) => {
|
||||||
|
return await models.$queryRaw(
|
||||||
|
`${withClause(when)}
|
||||||
|
SELECT time, json_build_array(
|
||||||
|
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
|
||||||
|
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
|
||||||
|
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
|
||||||
|
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0))
|
||||||
|
) AS data
|
||||||
|
FROM times
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ItemAct".created_at, 0 as airdrop,
|
||||||
|
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
|
||||||
|
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
|
||||||
|
0 as referral
|
||||||
|
FROM "ItemAct"
|
||||||
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
|
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
|
||||||
|
FROM "ReferralAct"
|
||||||
|
WHERE ${intervalClause(when, 'ReferralAct', false)})
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
|
||||||
|
FROM "Earn"
|
||||||
|
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,9 @@ import sub from './sub'
|
|||||||
import upload from './upload'
|
import upload from './upload'
|
||||||
import growth from './growth'
|
import growth from './growth'
|
||||||
import search from './search'
|
import search from './search'
|
||||||
|
import rewards from './rewards'
|
||||||
|
import referrals from './referrals'
|
||||||
import { GraphQLJSONObject } from 'graphql-type-json'
|
import { GraphQLJSONObject } from 'graphql-type-json'
|
||||||
|
|
||||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, growth, search, { JSONObject: GraphQLJSONObject }]
|
upload, growth, search, rewards, referrals, { JSONObject: GraphQLJSONObject }]
|
||||||
|
@ -8,18 +8,19 @@ import {
|
|||||||
BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES,
|
BOOST_MIN, ITEM_SPAM_INTERVAL, MAX_POLL_NUM_CHOICES,
|
||||||
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
|
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
|
||||||
} from '../../lib/constants'
|
} from '../../lib/constants'
|
||||||
|
import { msatsToSats } from '../../lib/format'
|
||||||
|
|
||||||
async function comments (me, models, id, sort) {
|
async function comments (me, models, id, sort) {
|
||||||
let orderBy
|
let orderBy
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'top':
|
case 'top':
|
||||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
|
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC`
|
||||||
break
|
break
|
||||||
case 'recent':
|
case 'recent':
|
||||||
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
|
orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, "Item".id DESC'
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".id DESC`
|
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ async function topOrderClause (sort, me, models) {
|
|||||||
case 'comments':
|
case 'comments':
|
||||||
return 'ORDER BY ncomments DESC'
|
return 'ORDER BY ncomments DESC'
|
||||||
case 'sats':
|
case 'sats':
|
||||||
return 'ORDER BY sats DESC'
|
return 'ORDER BY msats DESC'
|
||||||
default:
|
default:
|
||||||
return await topOrderByWeightedSats(me, models)
|
return await topOrderByWeightedSats(me, models)
|
||||||
}
|
}
|
||||||
@ -125,6 +126,21 @@ export async function filterClause (me, models) {
|
|||||||
return clause
|
return clause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recentClause (type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'links':
|
||||||
|
return ' AND url IS NOT NULL'
|
||||||
|
case 'discussions':
|
||||||
|
return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL'
|
||||||
|
case 'polls':
|
||||||
|
return ' AND "pollCost" IS NOT NULL'
|
||||||
|
case 'bios':
|
||||||
|
return ' AND bio = true'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
itemRepetition: async (parent, { parentId }, { me, models }) => {
|
||||||
@ -169,7 +185,7 @@ export default {
|
|||||||
comments
|
comments
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => {
|
items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => {
|
||||||
const decodedCursor = decodeCursor(cursor)
|
const decodedCursor = decodeCursor(cursor)
|
||||||
let items; let user; let pins; let subFull
|
let items; let user; let pins; let subFull
|
||||||
|
|
||||||
@ -211,6 +227,7 @@ export default {
|
|||||||
${subClause(3)}
|
${subClause(3)}
|
||||||
${activeOrMine()}
|
${activeOrMine()}
|
||||||
${await filterClause(me, models)}
|
${await filterClause(me, models)}
|
||||||
|
${recentClause(type)}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
|
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, sub || 'NULL')
|
||||||
@ -235,9 +252,6 @@ export default {
|
|||||||
|
|
||||||
switch (subFull?.rankingType) {
|
switch (subFull?.rankingType) {
|
||||||
case 'AUCTION':
|
case 'AUCTION':
|
||||||
// it might be sufficient to sort by the floor(maxBid / 1000) desc, created_at desc
|
|
||||||
// we pull from their wallet
|
|
||||||
// TODO: need to filter out by payment status
|
|
||||||
items = await models.$queryRaw(`
|
items = await models.$queryRaw(`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM (
|
FROM (
|
||||||
@ -691,6 +705,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Item: {
|
Item: {
|
||||||
|
sats: async (item, args, { models }) => {
|
||||||
|
return msatsToSats(item.msats)
|
||||||
|
},
|
||||||
|
commentSats: async (item, args, { models }) => {
|
||||||
|
return msatsToSats(item.commentMsats)
|
||||||
|
},
|
||||||
isJob: async (item, args, { models }) => {
|
isJob: async (item, args, { models }) => {
|
||||||
return item.subName === 'jobs'
|
return item.subName === 'jobs'
|
||||||
},
|
},
|
||||||
@ -772,25 +792,17 @@ export default {
|
|||||||
return comments(me, models, item.id, 'hot')
|
return comments(me, models, item.id, 'hot')
|
||||||
},
|
},
|
||||||
upvotes: async (item, args, { models }) => {
|
upvotes: async (item, args, { models }) => {
|
||||||
const { sum: { sats } } = await models.itemAct.aggregate({
|
const [{ count }] = await models.$queryRaw(`
|
||||||
sum: {
|
SELECT COUNT(DISTINCT "userId") as count
|
||||||
sats: true
|
FROM "ItemAct"
|
||||||
},
|
WHERE act = 'TIP' AND "itemId" = $1`, Number(item.id))
|
||||||
where: {
|
|
||||||
itemId: Number(item.id),
|
|
||||||
userId: {
|
|
||||||
not: Number(item.userId)
|
|
||||||
},
|
|
||||||
act: 'VOTE'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return sats || 0
|
return count
|
||||||
},
|
},
|
||||||
boost: async (item, args, { models }) => {
|
boost: async (item, args, { models }) => {
|
||||||
const { sum: { sats } } = await models.itemAct.aggregate({
|
const { sum: { msats } } = await models.itemAct.aggregate({
|
||||||
sum: {
|
sum: {
|
||||||
sats: true
|
msats: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
itemId: Number(item.id),
|
itemId: Number(item.id),
|
||||||
@ -798,7 +810,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return sats || 0
|
return (msats && msatsToSats(msats)) || 0
|
||||||
},
|
},
|
||||||
wvotes: async (item) => {
|
wvotes: async (item) => {
|
||||||
return item.weightedVotes - item.weightedDownVotes
|
return item.weightedVotes - item.weightedDownVotes
|
||||||
@ -806,9 +818,9 @@ export default {
|
|||||||
meSats: async (item, args, { me, models }) => {
|
meSats: async (item, args, { me, models }) => {
|
||||||
if (!me) return 0
|
if (!me) return 0
|
||||||
|
|
||||||
const { sum: { sats } } = await models.itemAct.aggregate({
|
const { sum: { msats } } = await models.itemAct.aggregate({
|
||||||
sum: {
|
sum: {
|
||||||
sats: true
|
msats: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
itemId: Number(item.id),
|
itemId: Number(item.id),
|
||||||
@ -818,13 +830,13 @@ export default {
|
|||||||
act: 'TIP'
|
act: 'TIP'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
act: 'VOTE'
|
act: 'FEE'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return sats || 0
|
return (msats && msatsToSats(msats)) || 0
|
||||||
},
|
},
|
||||||
meDontLike: async (item, args, { me, models }) => {
|
meDontLike: async (item, args, { me, models }) => {
|
||||||
if (!me) return false
|
if (!me) return false
|
||||||
@ -1011,7 +1023,7 @@ export const SELECT =
|
|||||||
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
"Item".text, "Item".url, "Item"."userId", "Item"."fwdUserId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||||
"Item".company, "Item".location, "Item".remote,
|
"Item".company, "Item".location, "Item".remote,
|
||||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
|
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost",
|
||||||
"Item".sats, "Item".ncomments, "Item"."commentSats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
"Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
||||||
"Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"`
|
"Item"."weightedDownVotes", "Item".freebie, ltree2text("Item"."path") AS "path"`
|
||||||
|
|
||||||
async function newTimedOrderByWeightedSats (me, models, num) {
|
async function newTimedOrderByWeightedSats (me, models, num) {
|
||||||
|
@ -98,7 +98,7 @@ export default {
|
|||||||
FROM "Item"
|
FROM "Item"
|
||||||
WHERE "Item"."userId" = $1
|
WHERE "Item"."userId" = $1
|
||||||
AND "maxBid" IS NOT NULL
|
AND "maxBid" IS NOT NULL
|
||||||
AND "statusUpdatedAt" <= $2
|
AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <> created_at
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT}+$3)`
|
LIMIT ${LIMIT}+$3)`
|
||||||
)
|
)
|
||||||
@ -106,12 +106,12 @@ export default {
|
|||||||
if (meFull.noteItemSats) {
|
if (meFull.noteItemSats) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
|
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
|
||||||
sum("ItemAct".sats) as "earnedSats", 'Votification' AS type
|
floor(sum("ItemAct".msats)/1000) as "earnedSats", 'Votification' AS type
|
||||||
FROM "Item"
|
FROM "Item"
|
||||||
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
|
||||||
WHERE "ItemAct"."userId" <> $1
|
WHERE "ItemAct"."userId" <> $1
|
||||||
AND "ItemAct".created_at <= $2
|
AND "ItemAct".created_at <= $2
|
||||||
AND "ItemAct".act in ('VOTE', 'TIP')
|
AND "ItemAct".act IN ('TIP', 'FEE')
|
||||||
AND "Item"."userId" = $1
|
AND "Item"."userId" = $1
|
||||||
GROUP BY "Item".id
|
GROUP BY "Item".id
|
||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
@ -160,6 +160,15 @@ export default {
|
|||||||
ORDER BY "sortTime" DESC
|
ORDER BY "sortTime" DESC
|
||||||
LIMIT ${LIMIT}+$3)`
|
LIMIT ${LIMIT}+$3)`
|
||||||
)
|
)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
|
||||||
|
'Referral' AS type
|
||||||
|
FROM users
|
||||||
|
WHERE "users"."referrerId" = $1
|
||||||
|
AND "inviteId" IS NULL
|
||||||
|
AND users.created_at <= $2
|
||||||
|
LIMIT ${LIMIT}+$3)`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meFull.noteEarning) {
|
if (meFull.noteEarning) {
|
||||||
|
53
api/resolvers/referrals.js
Normal file
53
api/resolvers/referrals.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { AuthenticationError } from 'apollo-server-micro'
|
||||||
|
import { withClause, intervalClause, timeUnit } from './growth'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Query: {
|
||||||
|
referrals: async (parent, { when }, { models, me }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new AuthenticationError('you must be logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ totalSats }] = await models.$queryRaw(`
|
||||||
|
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
|
||||||
|
FROM "ReferralAct"
|
||||||
|
WHERE ${intervalClause(when, 'ReferralAct', true)}
|
||||||
|
"ReferralAct"."referrerId" = $1
|
||||||
|
`, Number(me.id))
|
||||||
|
|
||||||
|
const [{ totalReferrals }] = await models.$queryRaw(`
|
||||||
|
SELECT count(*) as "totalReferrals"
|
||||||
|
FROM users
|
||||||
|
WHERE ${intervalClause(when, 'users', true)}
|
||||||
|
"referrerId" = $1
|
||||||
|
`, Number(me.id))
|
||||||
|
|
||||||
|
const stats = await models.$queryRaw(
|
||||||
|
`${withClause(when)}
|
||||||
|
SELECT time, json_build_array(
|
||||||
|
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
|
||||||
|
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
|
||||||
|
) AS data
|
||||||
|
FROM times
|
||||||
|
LEFT JOIN
|
||||||
|
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
|
||||||
|
FROM "ReferralAct"
|
||||||
|
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
||||||
|
WHERE ${intervalClause(when, 'ReferralAct', true)}
|
||||||
|
"ReferralAct"."referrerId" = $1)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
|
||||||
|
FROM users
|
||||||
|
WHERE ${intervalClause(when, 'users', true)}
|
||||||
|
"referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC`, Number(me.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSats,
|
||||||
|
totalReferrals,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
api/resolvers/rewards.js
Normal file
49
api/resolvers/rewards.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { AuthenticationError } from 'apollo-server-micro'
|
||||||
|
import serialize from './serial'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Query: {
|
||||||
|
expectedRewards: async (parent, args, { models }) => {
|
||||||
|
// get the last reward time, then get all contributions to rewards since then
|
||||||
|
const lastReward = await models.earn.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [result] = await models.$queryRaw`
|
||||||
|
SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
|
||||||
|
json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)),
|
||||||
|
json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION'))), 0)),
|
||||||
|
json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
|
||||||
|
json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0))
|
||||||
|
) AS sources
|
||||||
|
FROM (
|
||||||
|
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type
|
||||||
|
FROM "ItemAct"
|
||||||
|
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
||||||
|
WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT sats::FLOAT, 'DONATION' as type
|
||||||
|
FROM "Donation"
|
||||||
|
WHERE created_at > ${lastReward.createdAt})
|
||||||
|
) subquery`
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
donateToRewards: async (parent, { sats }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new AuthenticationError('you must be logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
await serialize(models,
|
||||||
|
models.$queryRaw(
|
||||||
|
'SELECT donate($1, $2)',
|
||||||
|
sats, Number(me.id)))
|
||||||
|
|
||||||
|
return sats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,12 @@ async function serialize (models, call) {
|
|||||||
if (error.message.includes('SN_INELIGIBLE')) {
|
if (error.message.includes('SN_INELIGIBLE')) {
|
||||||
bail(new Error('user ineligible for gift'))
|
bail(new Error('user ineligible for gift'))
|
||||||
}
|
}
|
||||||
|
if (error.message.includes('SN_UNSUPPORTED')) {
|
||||||
|
bail(new Error('unsupported action'))
|
||||||
|
}
|
||||||
|
if (error.message.includes('SN_DUPLICATE')) {
|
||||||
|
bail(new Error('duplicate not allowed'))
|
||||||
|
}
|
||||||
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
|
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
|
||||||
bail(new Error('faucet has been revoked or is exhausted'))
|
bail(new Error('faucet has been revoked or is exhausted'))
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { AuthenticationError, UserInputError } from 'apollo-server-errors'
|
import { AuthenticationError, UserInputError } from 'apollo-server-errors'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
|
import { msatsToSats } from '../../lib/format'
|
||||||
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
|
|
||||||
@ -92,11 +93,20 @@ export default {
|
|||||||
let users
|
let users
|
||||||
if (sort === 'spent') {
|
if (sort === 'spent') {
|
||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
SELECT users.*, sum("ItemAct".sats) as spent
|
SELECT users.*, sum(sats_spent) as spent
|
||||||
FROM "ItemAct"
|
FROM
|
||||||
JOIN users on "ItemAct"."userId" = users.id
|
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
|
||||||
WHERE "ItemAct".created_at <= $1
|
FROM "ItemAct"
|
||||||
${within('ItemAct', when)}
|
WHERE "ItemAct".created_at <= $1
|
||||||
|
${within('ItemAct', when)}
|
||||||
|
GROUP BY "userId")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT "userId", sats as sats_spent
|
||||||
|
FROM "Donation"
|
||||||
|
WHERE created_at <= $1
|
||||||
|
${within('Donation', when)})) spending
|
||||||
|
JOIN users on spending."userId" = users.id
|
||||||
|
AND NOT users."hideFromTopUsers"
|
||||||
GROUP BY users.id, users.name
|
GROUP BY users.id, users.name
|
||||||
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
ORDER BY spent DESC NULLS LAST, users.created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
@ -107,6 +117,7 @@ export default {
|
|||||||
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"
|
||||||
${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 nitems DESC NULLS LAST, users.created_at DESC
|
||||||
@ -118,26 +129,47 @@ export default {
|
|||||||
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 NOT NULL
|
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NOT NULL
|
||||||
|
AND NOT users."hideFromTopUsers"
|
||||||
${within('Item', when)}
|
${within('Item', when)}
|
||||||
GROUP BY users.id
|
GROUP BY users.id
|
||||||
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') {
|
||||||
|
users = await models.$queryRaw(`
|
||||||
|
SELECT users.*, count(*) as referrals
|
||||||
|
FROM users
|
||||||
|
JOIN "users" referree on users.id = referree."referrerId"
|
||||||
|
WHERE referree.created_at <= $1
|
||||||
|
AND NOT users."hideFromTopUsers"
|
||||||
|
${within('referree', when)}
|
||||||
|
GROUP BY users.id
|
||||||
|
ORDER BY referrals DESC NULLS LAST, users.created_at DESC
|
||||||
|
OFFSET $2
|
||||||
|
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
|
||||||
} else {
|
} else {
|
||||||
users = await models.$queryRaw(`
|
users = await models.$queryRaw(`
|
||||||
SELECT u.id, u.name, u."photoId", sum(amount) as stacked
|
SELECT u.id, u.name, u."photoId", floor(sum(amount)/1000) as stacked
|
||||||
FROM
|
FROM
|
||||||
((SELECT users.*, "ItemAct".sats as amount
|
((SELECT users.*, "ItemAct".msats as amount
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
JOIN users on "Item"."userId" = users.id
|
JOIN users on "Item"."userId" = users.id
|
||||||
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> users.id AND "ItemAct".created_at <= $1
|
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> users.id AND "ItemAct".created_at <= $1
|
||||||
|
AND NOT users."hideFromTopUsers"
|
||||||
${within('ItemAct', when)})
|
${within('ItemAct', when)})
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT users.*, "Earn".msats/1000 as amount
|
(SELECT users.*, "Earn".msats as amount
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
JOIN users on users.id = "Earn"."userId"
|
JOIN users on users.id = "Earn"."userId"
|
||||||
WHERE "Earn".msats > 0 ${within('Earn', when)})) u
|
WHERE "Earn".msats > 0 ${within('Earn', when)}
|
||||||
|
AND NOT users."hideFromTopUsers")
|
||||||
|
UNION ALL
|
||||||
|
(SELECT users.*, "ReferralAct".msats as amount
|
||||||
|
FROM "ReferralAct"
|
||||||
|
JOIN users on users.id = "ReferralAct"."referrerId"
|
||||||
|
WHERE "ReferralAct".msats > 0 ${within('ReferralAct', when)}
|
||||||
|
AND NOT users."hideFromTopUsers")) u
|
||||||
GROUP BY u.id, u.name, u.created_at, u."photoId"
|
GROUP BY u.id, u.name, u.created_at, u."photoId"
|
||||||
ORDER BY stacked DESC NULLS LAST, created_at DESC
|
ORDER BY stacked DESC NULLS LAST, created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
@ -149,6 +181,130 @@ export default {
|
|||||||
users
|
users
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
hasNewNotes: async (parent, args, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||||
|
const lastChecked = user.checkedNotesAt || new Date(0)
|
||||||
|
|
||||||
|
// check if any votes have been cast for them since checkedNotesAt
|
||||||
|
if (user.noteItemSats) {
|
||||||
|
const votes = await models.$queryRaw(`
|
||||||
|
SELECT "ItemAct".id, "ItemAct".created_at
|
||||||
|
FROM "Item"
|
||||||
|
JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id
|
||||||
|
WHERE "ItemAct"."userId" <> $1
|
||||||
|
AND "ItemAct".created_at > $2
|
||||||
|
AND "Item"."userId" = $1
|
||||||
|
AND "ItemAct".act = 'TIP'
|
||||||
|
LIMIT 1`, me.id, lastChecked)
|
||||||
|
if (votes.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if they have any replies since checkedNotesAt
|
||||||
|
const newReplies = await models.$queryRaw(`
|
||||||
|
SELECT "Item".id, "Item".created_at
|
||||||
|
FROM "Item"
|
||||||
|
JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
||||||
|
WHERE p."userId" = $1
|
||||||
|
AND "Item".created_at > $2 AND "Item"."userId" <> $1
|
||||||
|
${await filterClause(me, models)}
|
||||||
|
LIMIT 1`, me.id, lastChecked)
|
||||||
|
if (newReplies.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if they have any mentions since checkedNotesAt
|
||||||
|
if (user.noteMentions) {
|
||||||
|
const newMentions = await models.$queryRaw(`
|
||||||
|
SELECT "Item".id, "Item".created_at
|
||||||
|
FROM "Mention"
|
||||||
|
JOIN "Item" ON "Mention"."itemId" = "Item".id
|
||||||
|
WHERE "Mention"."userId" = $1
|
||||||
|
AND "Mention".created_at > $2
|
||||||
|
AND "Item"."userId" <> $1
|
||||||
|
LIMIT 1`, me.id, lastChecked)
|
||||||
|
if (newMentions.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await models.item.findFirst({
|
||||||
|
where: {
|
||||||
|
maxBid: {
|
||||||
|
not: null
|
||||||
|
},
|
||||||
|
userId: me.id,
|
||||||
|
statusUpdatedAt: {
|
||||||
|
gt: lastChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (job && job.statusUpdatedAt > job.createdAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.noteEarning) {
|
||||||
|
const earn = await models.earn.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
createdAt: {
|
||||||
|
gt: lastChecked
|
||||||
|
},
|
||||||
|
msats: {
|
||||||
|
gte: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (earn) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.noteDeposits) {
|
||||||
|
const invoice = await models.invoice.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
confirmedAt: {
|
||||||
|
gt: lastChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (invoice) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if new invites have been redeemed
|
||||||
|
if (user.noteInvites) {
|
||||||
|
const newInvitees = await models.$queryRaw(`
|
||||||
|
SELECT "Invite".id
|
||||||
|
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
|
||||||
|
WHERE "Invite"."userId" = $1
|
||||||
|
AND users.created_at > $2
|
||||||
|
LIMIT 1`, me.id, lastChecked)
|
||||||
|
if (newInvitees.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const referral = await models.user.findFirst({
|
||||||
|
where: {
|
||||||
|
referrerId: me.id,
|
||||||
|
createdAt: {
|
||||||
|
gt: lastChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (referral) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
searchUsers: async (parent, { q, limit, similarity }, { models }) => {
|
searchUsers: async (parent, { q, limit, similarity }, { models }) => {
|
||||||
return await models.$queryRaw`
|
return await models.$queryRaw`
|
||||||
SELECT * FROM users where id > 615 AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
SELECT * FROM users where id > 615 AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
||||||
@ -178,12 +334,29 @@ export default {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setSettings: async (parent, data, { me, models }) => {
|
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new AuthenticationError('you must be logged in')
|
throw new AuthenticationError('you must be logged in')
|
||||||
}
|
}
|
||||||
|
|
||||||
return await models.user.update({ where: { id: me.id }, data })
|
if (nostrRelays?.length) {
|
||||||
|
const connectOrCreate = []
|
||||||
|
for (const nr of nostrRelays) {
|
||||||
|
await models.nostrRelay.upsert({
|
||||||
|
where: { addr: nr },
|
||||||
|
update: { addr: nr },
|
||||||
|
create: { addr: nr }
|
||||||
|
})
|
||||||
|
connectOrCreate.push({
|
||||||
|
where: { userId_nostrRelayAddr: { userId: me.id, nostrRelayAddr: nr } },
|
||||||
|
create: { nostrRelayAddr: nr }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {}, connectOrCreate } } })
|
||||||
|
} else {
|
||||||
|
return await models.user.update({ where: { id: me.id }, data: { ...data, nostrRelays: { deleteMany: {} } } })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@ -310,22 +483,27 @@ export default {
|
|||||||
|
|
||||||
if (!when) {
|
if (!when) {
|
||||||
// forever
|
// forever
|
||||||
return Math.floor((user.stackedMsats || 0) / 1000)
|
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
|
||||||
} else {
|
} else {
|
||||||
const [{ stacked }] = await models.$queryRaw(`
|
const [{ stacked }] = await models.$queryRaw(`
|
||||||
SELECT sum(amount) as stacked
|
SELECT sum(amount) as stacked
|
||||||
FROM
|
FROM
|
||||||
((SELECT sum("ItemAct".sats) as amount
|
((SELECT coalesce(sum("ItemAct".msats),0) as amount
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2
|
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2
|
||||||
AND "ItemAct".created_at >= $1)
|
AND "ItemAct".created_at >= $1)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT sum("Earn".msats/1000) as amount
|
(SELECT coalesce(sum("ReferralAct".msats),0) as amount
|
||||||
|
FROM "ReferralAct"
|
||||||
|
WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $2
|
||||||
|
AND "ReferralAct".created_at >= $1)
|
||||||
|
UNION ALL
|
||||||
|
(SELECT coalesce(sum("Earn".msats), 0) as amount
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE "Earn".msats > 0 AND "Earn"."userId" = $2
|
WHERE "Earn".msats > 0 AND "Earn"."userId" = $2
|
||||||
AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id))
|
AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id))
|
||||||
return stacked || 0
|
return (stacked && msatsToSats(stacked)) || 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
spent: async (user, { when }, { models }) => {
|
spent: async (user, { when }, { models }) => {
|
||||||
@ -333,9 +511,9 @@ export default {
|
|||||||
return user.spent
|
return user.spent
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sum: { sats } } = await models.itemAct.aggregate({
|
const { sum: { msats } } = await models.itemAct.aggregate({
|
||||||
sum: {
|
sum: {
|
||||||
sats: true
|
msats: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -345,13 +523,23 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return sats || 0
|
return (msats && msatsToSats(msats)) || 0
|
||||||
|
},
|
||||||
|
referrals: async (user, { when }, { models }) => {
|
||||||
|
return await models.user.count({
|
||||||
|
where: {
|
||||||
|
referrerId: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: withinDate(when)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
sats: async (user, args, { models, me }) => {
|
sats: async (user, args, { models, me }) => {
|
||||||
if (me?.id !== user.id) {
|
if (me?.id !== user.id) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return Math.floor(user.msats / 1000.0)
|
return msatsToSats(user.msats)
|
||||||
},
|
},
|
||||||
bio: async (user, args, { models }) => {
|
bio: async (user, args, { models }) => {
|
||||||
return getItem(user, { id: user.bioId }, { models })
|
return getItem(user, { id: user.bioId }, { models })
|
||||||
@ -363,113 +551,12 @@ export default {
|
|||||||
|
|
||||||
return invites.length > 0
|
return invites.length > 0
|
||||||
},
|
},
|
||||||
hasNewNotes: async (user, args, { me, models }) => {
|
nostrRelays: async (user, args, { models }) => {
|
||||||
const lastChecked = user.checkedNotesAt || new Date(0)
|
const relays = await models.userNostrRelay.findMany({
|
||||||
|
where: { userId: user.id }
|
||||||
// check if any votes have been cast for them since checkedNotesAt
|
|
||||||
if (user.noteItemSats) {
|
|
||||||
const votes = await models.$queryRaw(`
|
|
||||||
SELECT "ItemAct".id, "ItemAct".created_at
|
|
||||||
FROM "Item"
|
|
||||||
JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id
|
|
||||||
WHERE "ItemAct"."userId" <> $1
|
|
||||||
AND "ItemAct".created_at > $2
|
|
||||||
AND "Item"."userId" = $1
|
|
||||||
AND "ItemAct".act IN ('VOTE', 'TIP')
|
|
||||||
LIMIT 1`, me.id, lastChecked)
|
|
||||||
if (votes.length > 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if they have any replies since checkedNotesAt
|
|
||||||
const newReplies = await models.$queryRaw(`
|
|
||||||
SELECT "Item".id, "Item".created_at
|
|
||||||
FROM "Item"
|
|
||||||
JOIN "Item" p ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
|
|
||||||
WHERE p."userId" = $1
|
|
||||||
AND "Item".created_at > $2 AND "Item"."userId" <> $1
|
|
||||||
${await filterClause(me, models)}
|
|
||||||
LIMIT 1`, me.id, lastChecked)
|
|
||||||
if (newReplies.length > 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if they have any mentions since checkedNotesAt
|
|
||||||
if (user.noteMentions) {
|
|
||||||
const newMentions = await models.$queryRaw(`
|
|
||||||
SELECT "Item".id, "Item".created_at
|
|
||||||
FROM "Mention"
|
|
||||||
JOIN "Item" ON "Mention"."itemId" = "Item".id
|
|
||||||
WHERE "Mention"."userId" = $1
|
|
||||||
AND "Mention".created_at > $2
|
|
||||||
AND "Item"."userId" <> $1
|
|
||||||
LIMIT 1`, me.id, lastChecked)
|
|
||||||
if (newMentions.length > 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = await models.item.findFirst({
|
|
||||||
where: {
|
|
||||||
maxBid: {
|
|
||||||
not: null
|
|
||||||
},
|
|
||||||
userId: me.id,
|
|
||||||
statusUpdatedAt: {
|
|
||||||
gt: lastChecked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
if (job) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.noteEarning) {
|
return relays?.map(r => r.nostrRelayAddr)
|
||||||
const earn = await models.earn.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
createdAt: {
|
|
||||||
gt: lastChecked
|
|
||||||
},
|
|
||||||
msats: {
|
|
||||||
gte: 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (earn) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.noteDeposits) {
|
|
||||||
const invoice = await models.invoice.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
confirmedAt: {
|
|
||||||
gt: lastChecked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (invoice) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if new invites have been redeemed
|
|
||||||
if (user.noteInvites) {
|
|
||||||
const newInvitees = await models.$queryRaw(`
|
|
||||||
SELECT "Invite".id
|
|
||||||
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
|
|
||||||
WHERE "Invite"."userId" = $1
|
|
||||||
AND users.created_at > $2
|
|
||||||
LIMIT 1`, me.id, lastChecked)
|
|
||||||
if (newInvitees.length > 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
|||||||
import lnpr from 'bolt11'
|
import lnpr from 'bolt11'
|
||||||
import { SELECT } from './item'
|
import { SELECT } from './item'
|
||||||
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
|
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
|
||||||
|
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
|
||||||
|
|
||||||
export async function getInvoice (parent, { id }, { me, models }) {
|
export async function getInvoice (parent, { id }, { me, models }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@ -93,11 +94,11 @@ export default {
|
|||||||
if (include.has('stacked')) {
|
if (include.has('stacked')) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('stacked' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
`(SELECT ('stacked' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
||||||
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".sats) * 1000 as msats,
|
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
||||||
0 as "msatsFee", NULL as status, 'stacked' as type
|
0 as "msatsFee", NULL as status, 'stacked' as type
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
WHERE "ItemAct"."userId" <> $1 AND "ItemAct".act <> 'BOOST'
|
WHERE act = 'TIP'
|
||||||
AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL)
|
AND (("Item"."userId" = $1 AND "Item"."fwdUserId" IS NULL)
|
||||||
OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId"))
|
OR ("Item"."fwdUserId" = $1 AND "ItemAct"."userId" <> "Item"."userId"))
|
||||||
AND "ItemAct".created_at <= $2
|
AND "ItemAct".created_at <= $2
|
||||||
@ -109,18 +110,31 @@ export default {
|
|||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
||||||
GROUP BY "userId", created_at)`)
|
GROUP BY "userId", created_at)`)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
|
||||||
|
created_at as "createdAt", msats,
|
||||||
|
0 as "msatsFee", NULL as status, 'referral' as type
|
||||||
|
FROM "ReferralAct"
|
||||||
|
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.has('spent')) {
|
if (include.has('spent')) {
|
||||||
queries.push(
|
queries.push(
|
||||||
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
`(SELECT ('spent' || "Item".id) as id, "Item".id as "factId", NULL as bolt11,
|
||||||
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".sats) * 1000 as msats,
|
MAX("ItemAct".created_at) as "createdAt", sum("ItemAct".msats) as msats,
|
||||||
0 as "msatsFee", NULL as status, 'spent' as type
|
0 as "msatsFee", NULL as status, 'spent' as type
|
||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||||
WHERE "ItemAct"."userId" = $1
|
WHERE "ItemAct"."userId" = $1
|
||||||
AND "ItemAct".created_at <= $2
|
AND "ItemAct".created_at <= $2
|
||||||
GROUP BY "Item".id)`)
|
GROUP BY "Item".id)`)
|
||||||
|
queries.push(
|
||||||
|
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
|
||||||
|
created_at as "createdAt", sats * 1000 as msats,
|
||||||
|
0 as "msatsFee", NULL as status, 'donation' as type
|
||||||
|
FROM "Donation"
|
||||||
|
WHERE "userId" = $1
|
||||||
|
AND created_at <= $2)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queries.length === 0) {
|
if (queries.length === 0) {
|
||||||
@ -156,6 +170,9 @@ export default {
|
|||||||
case 'spent':
|
case 'spent':
|
||||||
f.msats *= -1
|
f.msats *= -1
|
||||||
break
|
break
|
||||||
|
case 'donation':
|
||||||
|
f.msats *= -1
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -254,10 +271,14 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Withdrawl: {
|
Withdrawl: {
|
||||||
satsPaying: w => Math.floor(w.msatsPaying / 1000),
|
satsPaying: w => msatsToSats(w.msatsPaying),
|
||||||
satsPaid: w => Math.floor(w.msatsPaid / 1000),
|
satsPaid: w => msatsToSats(w.msatsPaid),
|
||||||
satsFeePaying: w => Math.floor(w.msatsFeePaying / 1000),
|
satsFeePaying: w => msatsToSats(w.msatsFeePaying),
|
||||||
satsFeePaid: w => Math.floor(w.msatsFeePaid / 1000)
|
satsFeePaid: w => msatsToSats(w.msatsFeePaid)
|
||||||
|
},
|
||||||
|
|
||||||
|
Invoice: {
|
||||||
|
satsReceived: i => msatsToSats(i.msatsReceived)
|
||||||
},
|
},
|
||||||
|
|
||||||
Fact: {
|
Fact: {
|
||||||
@ -271,7 +292,9 @@ export default {
|
|||||||
WHERE id = $1`, Number(fact.factId))
|
WHERE id = $1`, Number(fact.factId))
|
||||||
|
|
||||||
return item
|
return item
|
||||||
}
|
},
|
||||||
|
sats: fact => msatsToSatsDecimal(fact.msats),
|
||||||
|
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,7 +308,7 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd
|
|||||||
throw new UserInputError('could not decode invoice')
|
throw new UserInputError('could not decode invoice')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decoded.mtokens || Number(decoded.mtokens) <= 0) {
|
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
|
||||||
throw new UserInputError('your invoice must specify an amount')
|
throw new UserInputError('your invoice must specify an amount')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ApolloClient, InMemoryCache } from '@apollo/client'
|
import { ApolloClient, InMemoryCache } from '@apollo/client'
|
||||||
import { SchemaLink } from '@apollo/client/link/schema'
|
import { SchemaLink } from '@apollo/client/link/schema'
|
||||||
import { mergeSchemas } from 'graphql-tools'
|
import { makeExecutableSchema } from 'graphql-tools'
|
||||||
import { getSession } from 'next-auth/client'
|
import { getSession } from 'next-auth/client'
|
||||||
import resolvers from './resolvers'
|
import resolvers from './resolvers'
|
||||||
import typeDefs from './typeDefs'
|
import typeDefs from './typeDefs'
|
||||||
@ -8,17 +8,17 @@ import models from './models'
|
|||||||
import { print } from 'graphql'
|
import { print } from 'graphql'
|
||||||
import lnd from './lnd'
|
import lnd from './lnd'
|
||||||
import search from './search'
|
import search from './search'
|
||||||
import { ME_SSR } from '../fragments/users'
|
import { ME } from '../fragments/users'
|
||||||
import { getPrice } from '../components/price'
|
import { getPrice } from '../components/price'
|
||||||
|
|
||||||
export default async function getSSRApolloClient (req, me = null) {
|
export default async function getSSRApolloClient (req, me = null) {
|
||||||
const session = req && await getSession({ req })
|
const session = req && await getSession({ req })
|
||||||
return new ApolloClient({
|
const client = new ApolloClient({
|
||||||
ssrMode: true,
|
ssrMode: true,
|
||||||
link: new SchemaLink({
|
link: new SchemaLink({
|
||||||
schema: mergeSchemas({
|
schema: makeExecutableSchema({
|
||||||
schemas: typeDefs,
|
typeDefs,
|
||||||
resolvers: resolvers
|
resolvers
|
||||||
}),
|
}),
|
||||||
context: {
|
context: {
|
||||||
models,
|
models,
|
||||||
@ -31,6 +31,8 @@ export default async function getSSRApolloClient (req, me = null) {
|
|||||||
}),
|
}),
|
||||||
cache: new InMemoryCache()
|
cache: new InMemoryCache()
|
||||||
})
|
})
|
||||||
|
await client.clearStore()
|
||||||
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
|
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
|
||||||
@ -40,7 +42,7 @@ export function getGetServerSideProps (query, variables = null, notFoundFunc, re
|
|||||||
const client = await getSSRApolloClient(req)
|
const client = await getSSRApolloClient(req)
|
||||||
|
|
||||||
const { data: { me } } = await client.query({
|
const { data: { me } } = await client.query({
|
||||||
query: ME_SSR
|
query: ME
|
||||||
})
|
})
|
||||||
|
|
||||||
const price = await getPrice(me?.fiatCurrency)
|
const price = await getPrice(me?.fiatCurrency)
|
||||||
|
@ -2,24 +2,12 @@ import { gql } from 'apollo-server-micro'
|
|||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
registrationGrowth: [RegistrationGrowth!]!
|
registrationGrowth(when: String): [TimeData!]!
|
||||||
activeGrowth: [TimeNum!]!
|
itemGrowth(when: String): [TimeData!]!
|
||||||
itemGrowth: [ItemGrowth!]!
|
spendingGrowth(when: String): [TimeData!]!
|
||||||
spentGrowth: [SpentGrowth!]!
|
spenderGrowth(when: String): [TimeData!]!
|
||||||
stackedGrowth: [StackedGrowth!]!
|
stackingGrowth(when: String): [TimeData!]!
|
||||||
earnerGrowth: [TimeNum!]!
|
stackerGrowth(when: String): [TimeData!]!
|
||||||
|
|
||||||
registrationsWeekly: Int!
|
|
||||||
activeWeekly: Int!
|
|
||||||
earnersWeekly: Int!
|
|
||||||
itemsWeekly: [NameValue!]!
|
|
||||||
spentWeekly: [NameValue!]!
|
|
||||||
stackedWeekly: [NameValue!]!
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeNum {
|
|
||||||
time: String!
|
|
||||||
num: Int!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NameValue {
|
type NameValue {
|
||||||
@ -27,31 +15,8 @@ export default gql`
|
|||||||
value: Int!
|
value: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationGrowth {
|
type TimeData {
|
||||||
time: String!
|
time: String!
|
||||||
invited: Int!
|
data: [NameValue!]!
|
||||||
organic: Int!
|
|
||||||
}
|
|
||||||
|
|
||||||
type ItemGrowth {
|
|
||||||
time: String!
|
|
||||||
jobs: Int!
|
|
||||||
posts: Int!
|
|
||||||
comments: Int!
|
|
||||||
}
|
|
||||||
|
|
||||||
type StackedGrowth {
|
|
||||||
time: String!
|
|
||||||
rewards: Int!
|
|
||||||
posts: Int!
|
|
||||||
comments: Int!
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpentGrowth {
|
|
||||||
time: String!
|
|
||||||
jobs: Int!
|
|
||||||
fees: Int!
|
|
||||||
boost: Int!
|
|
||||||
tips: Int!
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -10,6 +10,8 @@ import invite from './invite'
|
|||||||
import sub from './sub'
|
import sub from './sub'
|
||||||
import upload from './upload'
|
import upload from './upload'
|
||||||
import growth from './growth'
|
import growth from './growth'
|
||||||
|
import rewards from './rewards'
|
||||||
|
import referrals from './referrals'
|
||||||
|
|
||||||
const link = gql`
|
const link = gql`
|
||||||
type Query {
|
type Query {
|
||||||
@ -26,4 +28,4 @@ const link = gql`
|
|||||||
`
|
`
|
||||||
|
|
||||||
export default [link, user, item, message, wallet, lnurl, notifications, invite,
|
export default [link, user, item, message, wallet, lnurl, notifications, invite,
|
||||||
sub, upload, growth]
|
sub, upload, growth, rewards, referrals]
|
||||||
|
@ -2,7 +2,7 @@ import { gql } from 'apollo-server-micro'
|
|||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Query {
|
extend type Query {
|
||||||
items(sub: String, sort: String, cursor: String, name: String, within: String): Items
|
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
|
||||||
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
|
moreFlatComments(sort: String!, cursor: String, name: String, within: String): Comments
|
||||||
item(id: ID!): Item
|
item(id: ID!): Item
|
||||||
comments(id: ID!, sort: String): [Item!]!
|
comments(id: ID!, sort: String): [Item!]!
|
||||||
|
@ -50,8 +50,12 @@ export default gql`
|
|||||||
sortTime: String!
|
sortTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Referral {
|
||||||
|
sortTime: String!
|
||||||
|
}
|
||||||
|
|
||||||
union Notification = Reply | Votification | Mention
|
union Notification = Reply | Votification | Mention
|
||||||
| Invitification | Earn | JobChanged | InvoicePaid
|
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: String
|
lastChecked: String
|
||||||
|
13
api/typeDefs/referrals.js
Normal file
13
api/typeDefs/referrals.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { gql } from 'apollo-server-micro'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
extend type Query {
|
||||||
|
referrals(when: String): Referrals!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Referrals {
|
||||||
|
totalSats: Int!
|
||||||
|
totalReferrals: Int!
|
||||||
|
stats: [TimeData!]!
|
||||||
|
}
|
||||||
|
`
|
16
api/typeDefs/rewards.js
Normal file
16
api/typeDefs/rewards.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { gql } from 'apollo-server-micro'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
extend type Query {
|
||||||
|
expectedRewards: ExpectedRewards!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
donateToRewards(sats: Int!): Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpectedRewards {
|
||||||
|
total: Int!
|
||||||
|
sources: [NameValue!]!
|
||||||
|
}
|
||||||
|
`
|
@ -9,6 +9,7 @@ export default gql`
|
|||||||
nameAvailable(name: String!): Boolean!
|
nameAvailable(name: String!): Boolean!
|
||||||
topUsers(cursor: String, when: String, sort: String): Users
|
topUsers(cursor: String, when: String, sort: String): Users
|
||||||
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
|
||||||
|
hasNewNotes: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Users {
|
type Users {
|
||||||
@ -18,10 +19,10 @@ export default gql`
|
|||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
setName(name: String!): Boolean
|
setName(name: String!): Boolean
|
||||||
setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!,
|
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
|
||||||
noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!,
|
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!,
|
||||||
wildWestMode: Boolean!, greeterMode: Boolean!): User
|
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User
|
||||||
setPhoto(photoId: ID!): Int!
|
setPhoto(photoId: ID!): Int!
|
||||||
upsertBio(bio: String!): User!
|
upsertBio(bio: String!): User!
|
||||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||||
@ -44,12 +45,15 @@ export default gql`
|
|||||||
ncomments(when: String): Int!
|
ncomments(when: String): Int!
|
||||||
stacked(when: String): Int!
|
stacked(when: String): Int!
|
||||||
spent(when: String): Int!
|
spent(when: String): Int!
|
||||||
|
referrals(when: String): Int!
|
||||||
freePosts: Int!
|
freePosts: Int!
|
||||||
freeComments: Int!
|
freeComments: Int!
|
||||||
hasNewNotes: Boolean!
|
|
||||||
hasInvites: Boolean!
|
hasInvites: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
|
turboTipping: Boolean!
|
||||||
fiatCurrency: String!
|
fiatCurrency: String!
|
||||||
|
nostrPubkey: String
|
||||||
|
nostrRelays: [String!]
|
||||||
bio: Item
|
bio: Item
|
||||||
bioId: Int
|
bioId: Int
|
||||||
photoId: Int
|
photoId: Int
|
||||||
@ -64,6 +68,7 @@ export default gql`
|
|||||||
noteInvites: Boolean!
|
noteInvites: Boolean!
|
||||||
noteJobIndicator: Boolean!
|
noteJobIndicator: Boolean!
|
||||||
hideInvoiceDesc: Boolean!
|
hideInvoiceDesc: Boolean!
|
||||||
|
hideFromTopUsers: Boolean!
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
greeterMode: Boolean!
|
greeterMode: Boolean!
|
||||||
lastCheckedJobs: String
|
lastCheckedJobs: String
|
||||||
|
@ -21,7 +21,7 @@ export default gql`
|
|||||||
expiresAt: String!
|
expiresAt: String!
|
||||||
cancelled: Boolean!
|
cancelled: Boolean!
|
||||||
confirmedAt: String
|
confirmedAt: String
|
||||||
msatsReceived: Int
|
satsReceived: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Withdrawl {
|
type Withdrawl {
|
||||||
@ -29,13 +29,9 @@ export default gql`
|
|||||||
createdAt: String!
|
createdAt: String!
|
||||||
hash: String!
|
hash: String!
|
||||||
bolt11: String!
|
bolt11: String!
|
||||||
msatsPaying: Int!
|
|
||||||
satsPaying: Int!
|
satsPaying: Int!
|
||||||
msatsPaid: Int
|
|
||||||
satsPaid: Int
|
satsPaid: Int
|
||||||
msatsFeePaying: Int!
|
|
||||||
satsFeePaying: Int!
|
satsFeePaying: Int!
|
||||||
msatsFeePaid: Int
|
|
||||||
satsFeePaid: Int
|
satsFeePaid: Int
|
||||||
status: String
|
status: String
|
||||||
}
|
}
|
||||||
@ -45,8 +41,8 @@ export default gql`
|
|||||||
factId: ID!
|
factId: ID!
|
||||||
bolt11: String
|
bolt11: String
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
msats: Int!
|
sats: Float!
|
||||||
msatsFee: Int
|
satsFee: Float
|
||||||
status: String
|
status: String
|
||||||
type: String!
|
type: String!
|
||||||
description: String
|
description: String
|
||||||
|
@ -18,6 +18,7 @@ import DontLikeThis from './dont-link-this'
|
|||||||
import Flag from '../svgs/flag-fill.svg'
|
import Flag from '../svgs/flag-fill.svg'
|
||||||
import { Badge } from 'react-bootstrap'
|
import { Badge } from 'react-bootstrap'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
|
import Share from './share'
|
||||||
|
|
||||||
function Parent ({ item, rootText }) {
|
function Parent ({ item, rootText }) {
|
||||||
const ParentFrag = () => (
|
const ParentFrag = () => (
|
||||||
@ -169,6 +170,7 @@ export default function Comment ({
|
|||||||
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
|
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
|
||||||
}}
|
}}
|
||||||
/>)}
|
/>)}
|
||||||
|
{topLevel && <Share item={item} />}
|
||||||
</div>
|
</div>
|
||||||
{edit
|
{edit
|
||||||
? (
|
? (
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
import MoreIcon from '../svgs/more-fill.svg'
|
import MoreIcon from '../svgs/more-fill.svg'
|
||||||
import { useFundError } from './fund-error'
|
import FundError from './fund-error'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
|
||||||
export default function DontLikeThis ({ id }) {
|
export default function DontLikeThis ({ id }) {
|
||||||
const { setError } = useFundError()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const [dontLikeThis] = useMutation(
|
const [dontLikeThis] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
@ -41,7 +42,9 @@ export default function DontLikeThis ({ id }) {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.toString().includes('insufficient funds')) {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
setError(true)
|
showModal(onClose => {
|
||||||
|
return <FundError onClose={onClose} />
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -35,6 +35,11 @@ const COLORS = {
|
|||||||
brandColor: 'rgba(0, 0, 0, 0.9)',
|
brandColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
grey: '#707070',
|
grey: '#707070',
|
||||||
link: '#007cbe',
|
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',
|
linkHover: '#004a72',
|
||||||
linkVisited: '#537587'
|
linkVisited: '#537587'
|
||||||
},
|
},
|
||||||
@ -54,6 +59,11 @@ const COLORS = {
|
|||||||
brandColor: 'var(--primary)',
|
brandColor: 'var(--primary)',
|
||||||
grey: '#969696',
|
grey: '#969696',
|
||||||
link: '#2e99d1',
|
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',
|
linkHover: '#007cbe',
|
||||||
linkVisited: '#56798E'
|
linkVisited: '#56798E'
|
||||||
}
|
}
|
||||||
@ -98,7 +108,7 @@ const AnalyticsPopover = (
|
|||||||
visitors
|
visitors
|
||||||
</a>
|
</a>
|
||||||
<span className='mx-2 text-dark'> \ </span>
|
<span className='mx-2 text-dark'> \ </span>
|
||||||
<Link href='/users/week' passHref>
|
<Link href='/users/day' passHref>
|
||||||
<a className='text-dark d-inline-flex'>
|
<a className='text-dark d-inline-flex'>
|
||||||
users
|
users
|
||||||
</a>
|
</a>
|
||||||
@ -126,7 +136,7 @@ export default function Footer ({ noLinks }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setLightning(localStorage.getItem('lnAnimate') || 'yes')
|
setLightning(localStorage.getItem('lnAnimate') || 'yes')
|
||||||
})
|
}, [])
|
||||||
|
|
||||||
const toggleLightning = () => {
|
const toggleLightning = () => {
|
||||||
if (lightning === 'yes') {
|
if (lightning === 'yes') {
|
||||||
@ -151,6 +161,13 @@ export default function Footer ({ noLinks }) {
|
|||||||
<DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' />
|
<DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' />
|
||||||
<LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' />
|
<LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' />
|
||||||
</div>}
|
</div>}
|
||||||
|
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||||
|
<Link href='/rewards' passHref>
|
||||||
|
<a className='nav-link p-0 d-inline-flex'>
|
||||||
|
rewards
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className='mb-0' style={{ fontWeight: 500 }}>
|
<div className='mb-0' style={{ fontWeight: 500 }}>
|
||||||
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
|
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
|
||||||
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
|
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>
|
||||||
|
@ -299,7 +299,7 @@ export function Input ({ label, groupClassName, ...props }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VariableInput ({ label, groupClassName, name, hint, max, readOnlyLen, ...props }) {
|
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) {
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<FieldArray name={name}>
|
<FieldArray name={name}>
|
||||||
@ -307,11 +307,11 @@ export function VariableInput ({ label, groupClassName, name, hint, max, readOnl
|
|||||||
const options = form.values[name]
|
const options = form.values[name]
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{options.map((_, i) => (
|
{options?.map((_, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<BootstrapForm.Row className='mb-2'>
|
<BootstrapForm.Row className='mb-2'>
|
||||||
<Col>
|
<Col>
|
||||||
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i > 1 ? 'optional' : undefined} />
|
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />
|
||||||
</Col>
|
</Col>
|
||||||
{options.length - 1 === i && options.length !== max
|
{options.length - 1 === i && options.length !== max
|
||||||
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
|
? <AddIcon className='fill-grey align-self-center pointer mx-2' onClick={() => fieldArrayHelpers.push('')} />
|
||||||
|
@ -1,48 +1,15 @@
|
|||||||
import { Button, Modal } from 'react-bootstrap'
|
|
||||||
import React, { useState, useCallback, useContext } from 'react'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
|
||||||
export const FundErrorContext = React.createContext({
|
export default function FundError ({ onClose }) {
|
||||||
error: null,
|
|
||||||
toggleError: () => {}
|
|
||||||
})
|
|
||||||
|
|
||||||
export function FundErrorProvider ({ children }) {
|
|
||||||
const [error, setError] = useState(false)
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
error,
|
|
||||||
setError: useCallback(e => setError(e), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FundErrorContext.Provider value={contextValue}>
|
<>
|
||||||
{children}
|
<p className='font-weight-bolder'>you need more sats</p>
|
||||||
</FundErrorContext.Provider>
|
<div className='d-flex justify-content-end'>
|
||||||
)
|
<Link href='/wallet?type=fund'>
|
||||||
}
|
<Button variant='success' onClick={onClose}>fund</Button>
|
||||||
|
</Link>
|
||||||
export function useFundError () {
|
</div>
|
||||||
const { error, setError } = useContext(FundErrorContext)
|
</>
|
||||||
return { error, setError }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FundErrorModal () {
|
|
||||||
const { error, setError } = useFundError()
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
show={error}
|
|
||||||
onHide={() => setError(false)}
|
|
||||||
>
|
|
||||||
<div className='modal-close' onClick={() => setError(false)}>X</div>
|
|
||||||
<Modal.Body>
|
|
||||||
<p className='font-weight-bolder'>you need more sats</p>
|
|
||||||
<div className='d-flex justify-content-end'>
|
|
||||||
<Link href='/wallet?type=fund'>
|
|
||||||
<Button variant='success' onClick={() => setError(false)}>fund</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import { Button, Container, NavDropdown } from 'react-bootstrap'
|
|||||||
import Price from './price'
|
import Price from './price'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { signOut, signIn } from 'next-auth/client'
|
import { signOut } from 'next-auth/client'
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { randInRange } from '../lib/rand'
|
import { randInRange } from '../lib/rand'
|
||||||
@ -35,7 +35,11 @@ export default function Header ({ sub }) {
|
|||||||
subLatestPost(name: $name)
|
subLatestPost(name: $name)
|
||||||
}
|
}
|
||||||
`, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
|
`, { variables: { name: 'jobs' }, pollInterval: 600000, fetchPolicy: 'network-only' })
|
||||||
|
const { data: hasNewNotes } = useQuery(gql`
|
||||||
|
{
|
||||||
|
hasNewNotes
|
||||||
|
}
|
||||||
|
`, { pollInterval: 30000, fetchPolicy: 'cache-and-network' })
|
||||||
const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
const [lastCheckedJobs, setLastCheckedJobs] = useState(new Date().getTime())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me) {
|
if (me) {
|
||||||
@ -46,19 +50,19 @@ export default function Header ({ sub }) {
|
|||||||
}
|
}
|
||||||
setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
|
setLastCheckedJobs(localStorage.getItem('lastCheckedJobs'))
|
||||||
}
|
}
|
||||||
})
|
}, [sub])
|
||||||
|
|
||||||
const Corner = () => {
|
const Corner = () => {
|
||||||
if (me) {
|
if (me) {
|
||||||
return (
|
return (
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel='shortcut icon' href={me?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
<link rel='shortcut icon' href={hasNewNotes?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||||
</Head>
|
</Head>
|
||||||
<Link href='/notifications' passHref>
|
<Link href='/notifications' passHref>
|
||||||
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
|
||||||
<NoteIcon />
|
<NoteIcon className='theme' />
|
||||||
{me?.hasNewNotes &&
|
{hasNewNotes?.hasNewNotes &&
|
||||||
<span className={styles.notification}>
|
<span className={styles.notification}>
|
||||||
<span className='invisible'>{' '}</span>
|
<span className='invisible'>{' '}</span>
|
||||||
</span>}
|
</span>}
|
||||||
@ -88,13 +92,8 @@ export default function Header ({ sub }) {
|
|||||||
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
||||||
</Link>
|
</Link>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<Link href='/invites' passHref>
|
<Link href='/referrals/month' passHref>
|
||||||
<NavDropdown.Item eventKey='invites'>invites
|
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
||||||
{me && !me.hasInvites &&
|
|
||||||
<div className='p-1 d-inline-block bg-success ml-1'>
|
|
||||||
<span className='invisible'>{' '}</span>
|
|
||||||
</div>}
|
|
||||||
</NavDropdown.Item>
|
|
||||||
</Link>
|
</Link>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
@ -135,18 +134,30 @@ export default function Header ({ sub }) {
|
|||||||
return () => { isMounted = false }
|
return () => { isMounted = false }
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
return path !== '/login' && !path.startsWith('/invites') &&
|
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||||
<Button
|
<div>
|
||||||
className='align-items-center d-flex pl-2 pr-3'
|
<Button
|
||||||
id='login'
|
className='align-items-center px-3 py-1 mr-2'
|
||||||
onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })}
|
id='signup'
|
||||||
>
|
style={{ borderWidth: '2px' }}
|
||||||
<LightningIcon
|
variant='outline-grey-darkmode'
|
||||||
width={17}
|
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||||
height={17}
|
>
|
||||||
className='mr-1'
|
login
|
||||||
/>login
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
|
className='align-items-center pl-2 py-1 pr-3'
|
||||||
|
style={{ borderWidth: '2px' }}
|
||||||
|
id='login'
|
||||||
|
onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })}
|
||||||
|
>
|
||||||
|
<LightningIcon
|
||||||
|
width={17}
|
||||||
|
height={17}
|
||||||
|
className='mr-1'
|
||||||
|
/>sign up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
.navLinkButton {
|
.navLinkButton {
|
||||||
border: 2px solid;
|
border: 2px solid;
|
||||||
|
padding: 0.2rem .9rem !important;
|
||||||
border-radius: .4rem;
|
border-radius: .4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +58,6 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown > a {
|
.dropdown>a {
|
||||||
padding-left: 0 !important;
|
padding-left: 0 !important;
|
||||||
}
|
}
|
@ -4,7 +4,7 @@ import ThumbDown from '../svgs/thumb-down-fill.svg'
|
|||||||
|
|
||||||
function InvoiceDefaultStatus ({ status }) {
|
function InvoiceDefaultStatus ({ status }) {
|
||||||
return (
|
return (
|
||||||
<div className='d-flex mt-2'>
|
<div className='d-flex mt-2 justify-content-center'>
|
||||||
<Moon className='spin fill-grey' />
|
<Moon className='spin fill-grey' />
|
||||||
<div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
|
<div className='ml-3 text-muted' style={{ fontWeight: '600' }}>{status}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -13,7 +13,7 @@ function InvoiceDefaultStatus ({ status }) {
|
|||||||
|
|
||||||
function InvoiceConfirmedStatus ({ status }) {
|
function InvoiceConfirmedStatus ({ status }) {
|
||||||
return (
|
return (
|
||||||
<div className='d-flex mt-2'>
|
<div className='d-flex mt-2 justify-content-center'>
|
||||||
<Check className='fill-success' />
|
<Check className='fill-success' />
|
||||||
<div className='ml-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
|
<div className='ml-3 text-success' style={{ fontWeight: '600' }}>{status}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -22,7 +22,7 @@ function InvoiceConfirmedStatus ({ status }) {
|
|||||||
|
|
||||||
function InvoiceFailedStatus ({ status }) {
|
function InvoiceFailedStatus ({ status }) {
|
||||||
return (
|
return (
|
||||||
<div className='d-flex mt-2'>
|
<div className='d-flex mt-2 justify-content-center'>
|
||||||
<ThumbDown className='fill-danger' />
|
<ThumbDown className='fill-danger' />
|
||||||
<div className='ml-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
|
<div className='ml-3 text-danger' style={{ fontWeight: '600' }}>{status}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,7 +5,7 @@ export function Invoice ({ invoice }) {
|
|||||||
let status = 'waiting for you'
|
let status = 'waiting for you'
|
||||||
if (invoice.confirmedAt) {
|
if (invoice.confirmedAt) {
|
||||||
variant = 'confirmed'
|
variant = 'confirmed'
|
||||||
status = `${invoice.msatsReceived / 1000} sats deposited`
|
status = `${invoice.satsReceived} sats deposited`
|
||||||
} else if (invoice.cancelled) {
|
} else if (invoice.cancelled) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'cancelled'
|
status = 'cancelled'
|
||||||
|
@ -1,87 +1,69 @@
|
|||||||
import { InputGroup, Modal } from 'react-bootstrap'
|
import { Button, InputGroup } from 'react-bootstrap'
|
||||||
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import { Form, Input, SubmitButton } from './form'
|
import { Form, Input, SubmitButton } from './form'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
import UpBolt from '../svgs/bolt.svg'
|
||||||
export const ItemActContext = React.createContext({
|
|
||||||
item: null,
|
|
||||||
setItem: () => {}
|
|
||||||
})
|
|
||||||
|
|
||||||
export function ItemActProvider ({ children }) {
|
|
||||||
const [item, setItem] = useState(null)
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
item,
|
|
||||||
setItem: useCallback(i => setItem(i), [])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemActContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</ItemActContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useItemAct () {
|
|
||||||
const { item, setItem } = useContext(ItemActContext)
|
|
||||||
return { item, setItem }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActSchema = Yup.object({
|
export const ActSchema = Yup.object({
|
||||||
amount: Yup.number().typeError('must be a number').required('required')
|
amount: Yup.number().typeError('must be a number').required('required')
|
||||||
.positive('must be positive').integer('must be whole')
|
.positive('must be positive').integer('must be whole')
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ItemActModal () {
|
export default function ItemAct ({ onClose, itemId, act, strike }) {
|
||||||
const { item, setItem } = useItemAct()
|
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
const [oValue, setOValue] = useState()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [item])
|
}, [onClose, itemId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Form
|
||||||
show={!!item}
|
initial={{
|
||||||
onHide={() => {
|
amount: me?.tipDefault,
|
||||||
setItem(null)
|
default: false
|
||||||
|
}}
|
||||||
|
schema={ActSchema}
|
||||||
|
onSubmit={async ({ amount }) => {
|
||||||
|
await act({
|
||||||
|
variables: {
|
||||||
|
id: itemId,
|
||||||
|
sats: Number(amount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await strike()
|
||||||
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='modal-close' onClick={() => setItem(null)}>X</div>
|
<Input
|
||||||
<Modal.Body>
|
label='amount'
|
||||||
<Form
|
name='amount'
|
||||||
initial={{
|
innerRef={inputRef}
|
||||||
amount: me?.tipDefault,
|
overrideValue={oValue}
|
||||||
default: false
|
required
|
||||||
}}
|
autoFocus
|
||||||
schema={ActSchema}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
onSubmit={async ({ amount }) => {
|
/>
|
||||||
await item.act({
|
<div>
|
||||||
variables: {
|
{[1, 10, 100, 1000, 10000].map(num =>
|
||||||
id: item.itemId,
|
<Button
|
||||||
sats: Number(amount)
|
size='sm'
|
||||||
}
|
className={`${num > 1 ? 'ml-2' : ''} mb-2`}
|
||||||
})
|
key={num}
|
||||||
await item.strike()
|
onClick={() => { setOValue(num) }}
|
||||||
setItem(null)
|
>
|
||||||
}}
|
<UpBolt
|
||||||
>
|
className='mr-1'
|
||||||
<Input
|
width={14}
|
||||||
label='amount'
|
height={14}
|
||||||
name='amount'
|
/>{num}
|
||||||
innerRef={inputRef}
|
</Button>)}
|
||||||
required
|
</div>
|
||||||
autoFocus
|
<div className='d-flex'>
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton>
|
||||||
/>
|
</div>
|
||||||
<div className='d-flex'>
|
</Form>
|
||||||
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import Toc from './table-of-contents'
|
import Toc from './table-of-contents'
|
||||||
import { Button, Image } from 'react-bootstrap'
|
import { Badge, Button, Image } from 'react-bootstrap'
|
||||||
import { SearchTitle } from './item'
|
import { SearchTitle } from './item'
|
||||||
import styles from './item.module.css'
|
import styles from './item.module.css'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { timeSince } from '../lib/time'
|
import { timeSince } from '../lib/time'
|
||||||
import EmailIcon from '../svgs/mail-open-line.svg'
|
import EmailIcon from '../svgs/mail-open-line.svg'
|
||||||
|
import Share from './share'
|
||||||
|
|
||||||
export default function ItemJob ({ item, toc, rank, children }) {
|
export default function ItemJob ({ item, toc, rank, children }) {
|
||||||
const isEmail = Yup.string().email().isValidSync(item.url)
|
const isEmail = Yup.string().email().isValidSync(item.url)
|
||||||
@ -59,19 +60,25 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
{item.mine &&
|
{item.mine &&
|
||||||
<>
|
(
|
||||||
<wbr />
|
<>
|
||||||
<span> \ </span>
|
<wbr />
|
||||||
<Link href={`/items/${item.id}/edit`} passHref>
|
<span> \ </span>
|
||||||
<a className='text-reset'>
|
<Link href={`/items/${item.id}/edit`} passHref>
|
||||||
edit
|
<a className='text-reset'>
|
||||||
</a>
|
edit
|
||||||
</Link>
|
</a>
|
||||||
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
</Link>
|
||||||
</>}
|
{item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>}
|
||||||
|
</>)}
|
||||||
|
{item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ml-1`}>PROMOTED</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{toc && <Toc text={item.text} />}
|
{toc &&
|
||||||
|
<>
|
||||||
|
<Share item={item} />
|
||||||
|
<Toc text={item.text} />
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>
|
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>
|
||||||
|
@ -14,6 +14,7 @@ import { newComments } from '../lib/new-comments'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import DontLikeThis from './dont-link-this'
|
import DontLikeThis from './dont-link-this'
|
||||||
import Flag from '../svgs/flag-fill.svg'
|
import Flag from '../svgs/flag-fill.svg'
|
||||||
|
import Share from './share'
|
||||||
import { abbrNum } from '../lib/format'
|
import { abbrNum } from '../lib/format'
|
||||||
|
|
||||||
export function SearchTitle ({ title }) {
|
export function SearchTitle ({ title }) {
|
||||||
@ -141,7 +142,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||||||
</div>
|
</div>
|
||||||
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
|
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
|
||||||
</div>
|
</div>
|
||||||
{toc && <Toc text={item.text} />}
|
{toc &&
|
||||||
|
<>
|
||||||
|
<Share item={item} />
|
||||||
|
<Toc text={item.text} />
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className={styles.children}>
|
<div className={styles.children}>
|
||||||
|
@ -14,6 +14,7 @@ import Avatar from './avatar'
|
|||||||
import BootstrapForm from 'react-bootstrap/Form'
|
import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import Alert from 'react-bootstrap/Alert'
|
import Alert from 'react-bootstrap/Alert'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
import ActionTooltip from './action-tooltip'
|
||||||
|
|
||||||
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
|
Yup.addMethod(Yup.string, 'or', function (schemas, msg) {
|
||||||
return this.test({
|
return this.test({
|
||||||
@ -183,7 +184,15 @@ export default function JobForm ({ item, sub }) {
|
|||||||
/>
|
/>
|
||||||
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
|
<PromoteJob item={item} sub={sub} storageKeyPrefix={storageKeyPrefix} />
|
||||||
{item && <StatusControl item={item} />}
|
{item && <StatusControl item={item} />}
|
||||||
<SubmitButton variant='secondary' className='mt-3'>{item ? 'save' : 'post'}</SubmitButton>
|
<div className='d-flex align-items-center mt-3'>
|
||||||
|
{item
|
||||||
|
? <SubmitButton variant='secondary'>save</SubmitButton>
|
||||||
|
: (
|
||||||
|
<ActionTooltip overlayText='1000 sats'>
|
||||||
|
<SubmitButton variant='secondary'>post <small> 1000 sats</small></SubmitButton>
|
||||||
|
</ActionTooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import Layout from './layout'
|
import Layout from './layout'
|
||||||
import styles from './layout-center.module.css'
|
import styles from './layout-center.module.css'
|
||||||
|
|
||||||
export default function LayoutCenter ({ children, ...props }) {
|
export default function LayoutCenter ({ children, footerLinks, ...props }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Layout noContain noFooterLinks {...props}>
|
<Layout noContain noFooterLinks={!footerLinks} {...props}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
import { signIn } from 'next-auth/client'
|
import { signIn } from 'next-auth/client'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { Col, Container, Row } from 'react-bootstrap'
|
||||||
|
import AccordianItem from './accordian-item'
|
||||||
import LnQR, { LnQRSkeleton } from './lnqr'
|
import LnQR, { LnQRSkeleton } from './lnqr'
|
||||||
|
import styles from './lightning-auth.module.css'
|
||||||
|
import BackIcon from '../svgs/arrow-left-line.svg'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
|
function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
|
||||||
const query = gql`
|
const query = gql`
|
||||||
@ -19,16 +24,67 @@ function LnQRAuth ({ k1, encodedUrl, callbackUrl }) {
|
|||||||
|
|
||||||
// output pubkey and k1
|
// output pubkey and k1
|
||||||
return (
|
return (
|
||||||
<>
|
<LnQR value={encodedUrl} status='waiting for you' />
|
||||||
<small className='mb-2'>
|
|
||||||
<a className='text-muted text-underline' href='https://github.com/fiatjaf/lnurl-rfc#lnurl-documents' target='_blank' rel='noreferrer' style={{ textDecoration: 'underline' }}>Does my wallet support lnurl-auth?</a>
|
|
||||||
</small>
|
|
||||||
<LnQR value={encodedUrl} status='waiting for you' />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LightningAuth ({ callbackUrl }) {
|
function LightningExplainer ({ text, children }) {
|
||||||
|
const router = useRouter()
|
||||||
|
return (
|
||||||
|
<Container sm>
|
||||||
|
<div className={styles.login}>
|
||||||
|
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
|
||||||
|
<h3 className='w-100 pb-2'>
|
||||||
|
{text || 'Login'} with Lightning
|
||||||
|
</h3>
|
||||||
|
<div className='font-weight-bold text-muted pb-4'>This is the most private way to use Stacker News. Just open your Lightning wallet and scan the QR code.</div>
|
||||||
|
<Row className='w-100 text-muted'>
|
||||||
|
<Col className='pl-0 mb-4' md>
|
||||||
|
<AccordianItem
|
||||||
|
header={`Which wallets can I use to ${(text || 'Login').toLowerCase()}?`}
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
<Row className='mb-3 no-gutters'>
|
||||||
|
You can use any wallet that supports lnurl-auth. These are some wallets you can use:
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col xs>
|
||||||
|
<ul className='mb-0'>
|
||||||
|
<li>Alby</li>
|
||||||
|
<li>Balance of Satoshis</li>
|
||||||
|
<li>Blixt</li>
|
||||||
|
<li>Breez</li>
|
||||||
|
<li>Blue Wallet</li>
|
||||||
|
<li>Coinos</li>
|
||||||
|
<li>LNBits</li>
|
||||||
|
<li>LNtxtbot</li>
|
||||||
|
</ul>
|
||||||
|
</Col>
|
||||||
|
<Col xs>
|
||||||
|
<ul>
|
||||||
|
<li>Phoenix</li>
|
||||||
|
<li>Simple Bitcoin Wallet</li>
|
||||||
|
<li>Sparrow Wallet</li>
|
||||||
|
<li>ThunderHub</li>
|
||||||
|
<li>Zap Desktop</li>
|
||||||
|
<li>Zeus</li>
|
||||||
|
</ul>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
|
||||||
|
{children}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LightningAuth ({ text, callbackUrl }) {
|
||||||
// query for challenge
|
// query for challenge
|
||||||
const [createAuth, { data, error }] = useMutation(gql`
|
const [createAuth, { data, error }] = useMutation(gql`
|
||||||
mutation createAuth {
|
mutation createAuth {
|
||||||
@ -38,13 +94,15 @@ export function LightningAuth ({ callbackUrl }) {
|
|||||||
}
|
}
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
useEffect(createAuth, [])
|
useEffect(() => {
|
||||||
|
createAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (error) return <div>error</div>
|
if (error) return <div>error</div>
|
||||||
|
|
||||||
if (!data) {
|
return (
|
||||||
return <LnQRSkeleton status='generating' />
|
<LightningExplainer text={text}>
|
||||||
}
|
{data ? <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <LnQRSkeleton status='generating' />}
|
||||||
|
</LightningExplainer>
|
||||||
return <LnQRAuth {...data.createAuth} callbackUrl={callbackUrl} />
|
)
|
||||||
}
|
}
|
||||||
|
8
components/lightning-auth.module.css
Normal file
8
components/lightning-auth.module.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.login {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
@ -8,11 +8,9 @@ import { ITEM_FIELDS } from '../fragments/items'
|
|||||||
import Item from './item'
|
import Item from './item'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||||
|
import { URL_REGEXP } from '../lib/url'
|
||||||
import FeeButton, { EditFeeButton } from './fee-button'
|
import FeeButton, { EditFeeButton } from './fee-button'
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
|
||||||
|
|
||||||
export function LinkForm ({ item, editThreshold }) {
|
export function LinkForm ({ item, editThreshold }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
@ -46,7 +44,7 @@ export function LinkForm ({ item, editThreshold }) {
|
|||||||
title: Yup.string().required('required').trim()
|
title: Yup.string().required('required').trim()
|
||||||
.max(MAX_TITLE_LENGTH,
|
.max(MAX_TITLE_LENGTH,
|
||||||
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
||||||
url: Yup.string().matches(URL, 'invalid url').required('required'),
|
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required'),
|
||||||
...AdvPostSchema(client)
|
...AdvPostSchema(client)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -7,20 +7,23 @@ import { useEffect } from 'react'
|
|||||||
export default function LnQR ({ value, webLn, statusVariant, status }) {
|
export default function LnQR ({ value, webLn, statusVariant, status }) {
|
||||||
const qrValue = 'lightning:' + value.toUpperCase()
|
const qrValue = 'lightning:' + value.toUpperCase()
|
||||||
|
|
||||||
useEffect(async () => {
|
useEffect(() => {
|
||||||
if (webLn) {
|
async function effect () {
|
||||||
try {
|
if (webLn) {
|
||||||
const provider = await requestProvider()
|
try {
|
||||||
await provider.sendPayment(value)
|
const provider = await requestProvider()
|
||||||
} catch (e) {
|
await provider.sendPayment(value)
|
||||||
console.log(e.message)
|
} catch (e) {
|
||||||
|
console.log(e.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
effect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a className='d-block p-3' style={{ background: 'white' }} href={qrValue}>
|
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
|
||||||
<QRCode
|
<QRCode
|
||||||
className='h-auto mw-100' value={qrValue} renderAs='svg' size={300}
|
className='h-auto mw-100' value={qrValue} renderAs='svg' size={300}
|
||||||
/>
|
/>
|
||||||
|
@ -8,7 +8,6 @@ import { Form, Input, SubmitButton } from '../components/form'
|
|||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Alert from 'react-bootstrap/Alert'
|
import Alert from 'react-bootstrap/Alert'
|
||||||
import LayoutCenter from '../components/layout-center'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { LightningAuth } from './lightning-auth'
|
import { LightningAuth } from './lightning-auth'
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ export const EmailSchema = Yup.object({
|
|||||||
email: Yup.string().email('email is no good').required('required')
|
email: Yup.string().email('email is no good').required('required')
|
||||||
})
|
})
|
||||||
|
|
||||||
export function EmailLoginForm ({ callbackUrl }) {
|
export function EmailLoginForm ({ text, callbackUrl }) {
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -34,12 +33,12 @@ export function EmailLoginForm ({ callbackUrl }) {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<SubmitButton variant='secondary' className={styles.providerButton}>Login with Email</SubmitButton>
|
<SubmitButton variant='secondary' className={styles.providerButton}>{text || 'Login'} with Email</SubmitButton>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Login ({ providers, callbackUrl, error, Header }) {
|
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) {
|
||||||
const errors = {
|
const errors = {
|
||||||
Signin: 'Try signing with a different account.',
|
Signin: 'Try signing with a different account.',
|
||||||
OAuthSignin: 'Try signing with a different account.',
|
OAuthSignin: 'Try signing with a different account.',
|
||||||
@ -56,59 +55,59 @@ export default function Login ({ providers, callbackUrl, error, Header }) {
|
|||||||
const [errorMessage, setErrorMessage] = useState(error && (errors[error] ?? errors.default))
|
const [errorMessage, setErrorMessage] = useState(error && (errors[error] ?? errors.default))
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
if (router.query.type === 'lightning') {
|
||||||
|
return <LightningAuth callbackUrl={callbackUrl} text={text} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutCenter noFooter>
|
<div className={styles.login}>
|
||||||
<div className={styles.login}>
|
{Header && <Header />}
|
||||||
{Header && <Header />}
|
{errorMessage &&
|
||||||
<div className='text-center font-weight-bold text-muted pb-4'>
|
<Alert
|
||||||
Not registered? Just login, we'll automatically create an account.
|
variant='danger'
|
||||||
</div>
|
onClose={() => setErrorMessage(undefined)}
|
||||||
{errorMessage &&
|
dismissible
|
||||||
<Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>}
|
>{errorMessage}
|
||||||
{router.query.type === 'lightning'
|
</Alert>}
|
||||||
? <LightningAuth callbackUrl={callbackUrl} />
|
<Button
|
||||||
: (
|
className={`mt-2 ${styles.providerButton}`}
|
||||||
<>
|
variant='primary'
|
||||||
<Button
|
onClick={() => router.push({
|
||||||
className={`mt-2 ${styles.providerButton}`}
|
pathname: router.pathname,
|
||||||
variant='primary'
|
query: { ...router.query, type: 'lightning' }
|
||||||
onClick={() => router.push({
|
})}
|
||||||
pathname: router.pathname,
|
>
|
||||||
query: { ...router.query, type: 'lightning' }
|
<LightningIcon
|
||||||
})}
|
width={20}
|
||||||
>
|
height={20}
|
||||||
<LightningIcon
|
className='mr-3'
|
||||||
width={20}
|
/>{text || 'Login'} with Lightning
|
||||||
height={20}
|
</Button>
|
||||||
className='mr-3'
|
{providers && Object.values(providers).map(provider => {
|
||||||
/>Login with Lightning
|
if (provider.name === 'Email' || provider.name === 'Lightning') {
|
||||||
</Button>
|
return null
|
||||||
{Object.values(providers).map(provider => {
|
}
|
||||||
if (provider.name === 'Email' || provider.name === 'Lightning') {
|
const [variant, Icon] =
|
||||||
return null
|
|
||||||
}
|
|
||||||
const [variant, Icon] =
|
|
||||||
provider.name === 'Twitter'
|
provider.name === 'Twitter'
|
||||||
? ['twitter', TwitterIcon]
|
? ['twitter', TwitterIcon]
|
||||||
: ['dark', GithubIcon]
|
: ['dark', GithubIcon]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={`mt-2 ${styles.providerButton}`}
|
className={`mt-2 ${styles.providerButton}`}
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
onClick={() => signIn(provider.id, { callbackUrl })}
|
onClick={() => signIn(provider.id, { callbackUrl })}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className='mr-3'
|
className='mr-3'
|
||||||
/>Login with {provider.name}
|
/>{text || 'Login'} with {provider.name}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
|
<div className='mt-2 text-center text-muted font-weight-bold'>or</div>
|
||||||
<EmailLoginForm callbackUrl={callbackUrl} />
|
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
|
||||||
</>)}
|
{Footer && <Footer />}
|
||||||
</div>
|
</div>
|
||||||
</LayoutCenter>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
51
components/modal.js
Normal file
51
components/modal.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
|
import { Modal } from 'react-bootstrap'
|
||||||
|
|
||||||
|
export const ShowModalContext = createContext(() => null)
|
||||||
|
|
||||||
|
export function ShowModalProvider ({ children }) {
|
||||||
|
const [modal, showModal] = useModal()
|
||||||
|
const contextValue = showModal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShowModalContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
{modal}
|
||||||
|
</ShowModalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShowModal () {
|
||||||
|
return useContext(ShowModalContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useModal () {
|
||||||
|
const [modalContent, setModalContent] = useState(null)
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
setModalContent(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const modal = useMemo(() => {
|
||||||
|
if (modalContent === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal onHide={onClose} show={!!modalContent}>
|
||||||
|
<div className='modal-close' onClick={onClose}>X</div>
|
||||||
|
<Modal.Body>
|
||||||
|
{modalContent}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}, [modalContent, onClose])
|
||||||
|
|
||||||
|
const showModal = useCallback(
|
||||||
|
(getContent) => {
|
||||||
|
setModalContent(getContent(onClose))
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [modal, showModal]
|
||||||
|
}
|
@ -20,7 +20,7 @@ function Notification ({ n }) {
|
|||||||
<div
|
<div
|
||||||
className='clickToContext'
|
className='clickToContext'
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (n.__typename === 'Earn') {
|
if (n.__typename === 'Earn' || n.__typename === 'Referral') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,41 +88,50 @@ function Notification ({ n }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: n.__typename === 'InvoicePaid'
|
: n.__typename === 'Referral'
|
||||||
? (
|
? (
|
||||||
<div className='font-weight-bold text-info ml-2 py-1'>
|
|
||||||
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
|
|
||||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
|
||||||
</div>)
|
|
||||||
: (
|
|
||||||
<>
|
<>
|
||||||
{n.__typename === 'Votification' &&
|
<small className='font-weight-bold text-secondary ml-2'>
|
||||||
<small className='font-weight-bold text-success ml-2'>
|
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
|
||||||
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</small>}
|
</small>
|
||||||
{n.__typename === 'Mention' &&
|
</>
|
||||||
<small className='font-weight-bold text-info ml-2'>
|
)
|
||||||
you were mentioned in
|
: n.__typename === 'InvoicePaid'
|
||||||
</small>}
|
? (
|
||||||
{n.__typename === 'JobChanged' &&
|
<div className='font-weight-bold text-info ml-2 py-1'>
|
||||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
|
||||||
{n.item.status === 'ACTIVE'
|
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||||
? 'your job is active again'
|
</div>)
|
||||||
: (n.item.status === 'NOSATS'
|
: (
|
||||||
? 'your job promotion ran out of sats'
|
<>
|
||||||
: 'your job has been stopped')}
|
{n.__typename === 'Votification' &&
|
||||||
</small>}
|
<small className='font-weight-bold text-success ml-2'>
|
||||||
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
|
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
||||||
{n.item.isJob
|
</small>}
|
||||||
? <ItemJob item={n.item} />
|
{n.__typename === 'Mention' &&
|
||||||
: n.item.title
|
<small className='font-weight-bold text-info ml-2'>
|
||||||
? <Item item={n.item} />
|
you were mentioned in
|
||||||
: (
|
</small>}
|
||||||
<div className='pb-2'>
|
{n.__typename === 'JobChanged' &&
|
||||||
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
|
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||||
</div>)}
|
{n.item.status === 'ACTIVE'
|
||||||
</div>
|
? 'your job is active again'
|
||||||
</>)}
|
: (n.item.status === 'NOSATS'
|
||||||
|
? 'your job promotion ran out of sats'
|
||||||
|
: 'your job has been stopped')}
|
||||||
|
</small>}
|
||||||
|
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
|
||||||
|
{n.item.isJob
|
||||||
|
? <ItemJob item={n.item} />
|
||||||
|
: n.item.title
|
||||||
|
? <Item item={n.item} />
|
||||||
|
: (
|
||||||
|
<div className='pb-2'>
|
||||||
|
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -86,6 +86,7 @@ export function PollForm ({ item, editThreshold }) {
|
|||||||
name='options'
|
name='options'
|
||||||
readOnlyLen={initialOptions?.length}
|
readOnlyLen={initialOptions?.length}
|
||||||
max={MAX_POLL_NUM_CHOICES}
|
max={MAX_POLL_NUM_CHOICES}
|
||||||
|
min={2}
|
||||||
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>
|
||||||
: null}
|
: null}
|
||||||
|
@ -6,12 +6,13 @@ import { useMe } from './me'
|
|||||||
import styles from './poll.module.css'
|
import styles from './poll.module.css'
|
||||||
import Check from '../svgs/checkbox-circle-fill.svg'
|
import Check from '../svgs/checkbox-circle-fill.svg'
|
||||||
import { signIn } from 'next-auth/client'
|
import { signIn } from 'next-auth/client'
|
||||||
import { useFundError } from './fund-error'
|
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
import FundError from './fund-error'
|
||||||
|
|
||||||
export default function Poll ({ item }) {
|
export default function Poll ({ item }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const { setError } = useFundError()
|
const showModal = useShowModal()
|
||||||
const [pollVote] = useMutation(
|
const [pollVote] = useMutation(
|
||||||
gql`
|
gql`
|
||||||
mutation pollVote($id: ID!) {
|
mutation pollVote($id: ID!) {
|
||||||
@ -60,7 +61,9 @@ export default function Poll ({ item }) {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.toString().includes('insufficient funds')) {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
setError(true)
|
showModal(onClose => {
|
||||||
|
return <FundError onClose={onClose} />
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,7 +78,7 @@ export default function Price () {
|
|||||||
|
|
||||||
if (asSats === 'yep') {
|
if (asSats === 'yep') {
|
||||||
return (
|
return (
|
||||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||||
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -86,14 +86,14 @@ export default function Price () {
|
|||||||
|
|
||||||
if (asSats === '1btc') {
|
if (asSats === '1btc') {
|
||||||
return (
|
return (
|
||||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||||
1sat=1sat
|
1sat=1sat
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button className='text-reset p-0' onClick={handleClick} variant='link'>
|
<Button className='text-reset p-0 line-height-1' onClick={handleClick} variant='link'>
|
||||||
{fiatSymbol + fixedDecimal(price, 0)}
|
{fiatSymbol + fixedDecimal(price, 0)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
@ -1,35 +1,25 @@
|
|||||||
import { Nav, Navbar } from 'react-bootstrap'
|
import { Form, Select } from './form'
|
||||||
import styles from './header.module.css'
|
import { useRouter } from 'next/router'
|
||||||
import Link from 'next/link'
|
|
||||||
|
export default function RecentHeader ({ type }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
export default function RecentHeader ({ itemType }) {
|
|
||||||
return (
|
return (
|
||||||
<Navbar className='pt-0'>
|
<Form
|
||||||
<Nav
|
initial={{
|
||||||
className={`${styles.navbarNav} justify-content-around`}
|
type: router.query.type || type || 'posts'
|
||||||
activeKey={itemType}
|
}}
|
||||||
>
|
>
|
||||||
<Nav.Item>
|
<div className='text-muted font-weight-bold mt-1 mb-3 d-flex justify-content-end align-items-center'>
|
||||||
<Link href='/recent' passHref>
|
<Select
|
||||||
<Nav.Link
|
groupClassName='mb-0 ml-2'
|
||||||
eventKey='posts'
|
className='w-auto'
|
||||||
className={styles.navLink}
|
name='type'
|
||||||
>
|
size='sm'
|
||||||
posts
|
items={['posts', 'comments', 'links', 'discussions', 'polls', 'bios']}
|
||||||
</Nav.Link>
|
onChange={(formik, e) => router.push(e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`)}
|
||||||
</Link>
|
/>
|
||||||
</Nav.Item>
|
</div>
|
||||||
<Nav.Item>
|
</Form>
|
||||||
<Link href='/recent/comments' passHref>
|
|
||||||
<Nav.Link
|
|
||||||
eventKey='comments'
|
|
||||||
className={styles.navLink}
|
|
||||||
>
|
|
||||||
comments
|
|
||||||
</Nav.Link>
|
|
||||||
</Link>
|
|
||||||
</Nav.Item>
|
|
||||||
</Nav>
|
|
||||||
</Navbar>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
46
components/share.js
Normal file
46
components/share.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import ShareIcon from '../svgs/share-fill.svg'
|
||||||
|
import copy from 'clipboard-copy'
|
||||||
|
import { useMe } from './me'
|
||||||
|
|
||||||
|
export default function Share ({ item }) {
|
||||||
|
const me = useMe()
|
||||||
|
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
|
||||||
|
|
||||||
|
return typeof window !== 'undefined' && navigator?.share
|
||||||
|
? (
|
||||||
|
<div className='ml-auto pointer d-flex align-items-center'>
|
||||||
|
<ShareIcon
|
||||||
|
className='mx-2 fill-grey theme'
|
||||||
|
onClick={() => {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: item.title || '',
|
||||||
|
text: '',
|
||||||
|
url
|
||||||
|
}).then(() => console.log('Successful share'))
|
||||||
|
.catch((error) => console.log('Error sharing', error))
|
||||||
|
} else {
|
||||||
|
console.log('no navigator.share')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
|
<Dropdown alignRight className='ml-auto pointer d-flex align-items-center' as='span'>
|
||||||
|
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||||
|
<ShareIcon className='mx-2 fill-grey theme' />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item
|
||||||
|
className='text-center'
|
||||||
|
onClick={async () => {
|
||||||
|
copy(url)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
copy link
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>)
|
||||||
|
}
|
@ -12,7 +12,7 @@ import React, { useEffect, useState } 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'
|
||||||
import {toString} from 'mdast-util-to-string'
|
import { toString } from 'mdast-util-to-string'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
|
|
||||||
function myRemarkPlugin () {
|
function myRemarkPlugin () {
|
||||||
@ -32,8 +32,6 @@ function myRemarkPlugin () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) {
|
function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [id] = useState(noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, '')))
|
const [id] = useState(noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, '')))
|
||||||
@ -44,20 +42,20 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
|
|||||||
<div className={styles.heading}>
|
<div 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}`}>
|
||||||
<Icon
|
<Icon
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const location = new URL(window.location)
|
const location = new URL(window.location)
|
||||||
location.hash = `${id}`
|
location.hash = `${id}`
|
||||||
copy(location.href)
|
copy(location.href)
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(false), 1500)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
}}
|
}}
|
||||||
width={18}
|
width={18}
|
||||||
height={18}
|
height={18}
|
||||||
className='fill-grey'
|
className='fill-grey'
|
||||||
/>
|
/>
|
||||||
</a>}
|
</a>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -66,7 +64,7 @@ export default function Text ({ topLevel, noFragments, nofollow, 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()
|
||||||
|
|
||||||
const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props})
|
const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
@ -108,10 +106,10 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
|
|||||||
// map: fix any highlighted links
|
// map: fix any highlighted links
|
||||||
children = children?.map(e =>
|
children = children?.map(e =>
|
||||||
typeof e === 'string'
|
typeof e === 'string'
|
||||||
? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => {
|
? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => {
|
||||||
return <mark key={`mark-${match}-${i}`}>{match}</mark>
|
return <mark key={`mark-${match}-${i}`}>{match}</mark>
|
||||||
})
|
})
|
||||||
: e)
|
: e)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* eslint-disable-next-line */
|
/* eslint-disable-next-line */
|
||||||
@ -135,7 +133,7 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ZoomableImage ({ src, topLevel, ...props }) {
|
export function ZoomableImage ({ src, topLevel, ...props }) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -59,13 +59,18 @@
|
|||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text blockquote>*:last-child {
|
.text blockquote:last-child {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text blockquote:has(+ :not(blockquote)) {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.text img {
|
.text img {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
border-radius: .4rem;
|
border-radius: .4rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -81,9 +86,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text blockquote {
|
.text blockquote {
|
||||||
border-left: 2px solid var(--theme-grey);
|
border-left: 4px solid var(--theme-quoteBar);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 0 0 0.5rem 0.5rem !important;
|
margin-left: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text li {
|
||||||
|
margin-top: .5rem;
|
||||||
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text h1 {
|
.text h1 {
|
||||||
|
@ -41,7 +41,7 @@ export default function TopHeader ({ cat }) {
|
|||||||
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
|
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
|
||||||
name='sort'
|
name='sort'
|
||||||
size='sm'
|
size='sm'
|
||||||
items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts'] : ['votes', 'comments', 'sats']}
|
items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts', 'referrals'] : ['votes', 'comments', 'sats']}
|
||||||
/>
|
/>
|
||||||
for
|
for
|
||||||
<Select
|
<Select
|
||||||
|
@ -2,15 +2,16 @@ import { LightningConsumer } from './lightning'
|
|||||||
import UpBolt from '../svgs/bolt.svg'
|
import UpBolt from '../svgs/bolt.svg'
|
||||||
import styles from './upvote.module.css'
|
import styles from './upvote.module.css'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
import { signIn } from 'next-auth/client'
|
import FundError from './fund-error'
|
||||||
import { useFundError } from './fund-error'
|
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { useItemAct } 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 { useRef, useState } from 'react'
|
import { 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 { useRouter } from 'next/router'
|
||||||
|
|
||||||
const getColor = (meSats) => {
|
const getColor = (meSats) => {
|
||||||
if (!meSats || meSats <= 10) {
|
if (!meSats || meSats <= 10) {
|
||||||
@ -63,8 +64,8 @@ const TipPopover = ({ target, show, handleClose }) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default function UpVote ({ item, className }) {
|
export default function UpVote ({ item, className }) {
|
||||||
const { setError } = useFundError()
|
const showModal = useShowModal()
|
||||||
const { setItem } = useItemAct()
|
const router = useRouter()
|
||||||
const [voteShow, _setVoteShow] = useState(false)
|
const [voteShow, _setVoteShow] = useState(false)
|
||||||
const [tipShow, _setTipShow] = useState(false)
|
const [tipShow, _setTipShow] = useState(false)
|
||||||
const ref = useRef()
|
const ref = useRef()
|
||||||
@ -123,11 +124,14 @@ export default function UpVote ({ item, className }) {
|
|||||||
return existingSats + sats
|
return existingSats + sats
|
||||||
},
|
},
|
||||||
meSats (existingSats = 0) {
|
meSats (existingSats = 0) {
|
||||||
if (existingSats === 0) {
|
if (sats <= me.sats) {
|
||||||
setVoteShow(true)
|
if (existingSats === 0) {
|
||||||
} else {
|
setVoteShow(true)
|
||||||
setTipShow(true)
|
} else {
|
||||||
|
setTipShow(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingSats + sats
|
return existingSats + sats
|
||||||
},
|
},
|
||||||
upvotes (existingUpvotes = 0) {
|
upvotes (existingUpvotes = 0) {
|
||||||
@ -152,11 +156,19 @@ export default function UpVote ({ item, className }) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const overlayText = () => {
|
// what should our next tip be?
|
||||||
if (me?.tipDefault) {
|
let sats = me?.tipDefault || 1
|
||||||
return `${me.tipDefault} sat${me.tipDefault > 1 ? 's' : ''}`
|
if (me?.turboTipping && item?.meSats) {
|
||||||
|
let raiseTip = sats
|
||||||
|
while (item?.meSats >= raiseTip) {
|
||||||
|
raiseTip *= 10
|
||||||
}
|
}
|
||||||
return '1 sat'
|
|
||||||
|
sats = raiseTip - item.meSats
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayText = () => {
|
||||||
|
return `${sats} sat${sats > 1 ? 's' : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = getColor(item?.meSats)
|
const color = getColor(item?.meSats)
|
||||||
@ -175,7 +187,8 @@ export default function UpVote ({ item, className }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTipShow(false)
|
setTipShow(false)
|
||||||
setItem({ itemId: item.id, act, strike })
|
showModal(onClose =>
|
||||||
|
<ItemAct onClose={onClose} itemId={item.id} act={act} strike={strike} />)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onShortPress={
|
onShortPress={
|
||||||
@ -196,24 +209,29 @@ export default function UpVote ({ item, className }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await act({
|
await act({
|
||||||
variables: { id: item.id, sats: me.tipDefault || 1 },
|
variables: { id: item.id, sats },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
act: {
|
act: {
|
||||||
id: `Item:${item.id}`,
|
id: `Item:${item.id}`,
|
||||||
sats: me.tipDefault || 1,
|
sats,
|
||||||
vote: 0
|
vote: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.toString().includes('insufficient funds')) {
|
if (error.toString().includes('insufficient funds')) {
|
||||||
setError(true)
|
showModal(onClose => {
|
||||||
|
return <FundError onClose={onClose} />
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: signIn
|
: async () => await router.push({
|
||||||
|
pathname: '/signup',
|
||||||
|
query: { callbackUrl: window.location.origin + router.asPath }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ActionTooltip notForm disable={item?.mine || fwd2me} overlayText={overlayText()}>
|
<ActionTooltip notForm disable={item?.mine || fwd2me} overlayText={overlayText()}>
|
||||||
|
@ -1,35 +1,26 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Nav, Navbar } from 'react-bootstrap'
|
import { Form, Select } from './form'
|
||||||
import styles from './header.module.css'
|
|
||||||
|
|
||||||
export function UsageHeader () {
|
export function UsageHeader () {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar className='pt-0'>
|
<Form
|
||||||
<Nav
|
initial={{
|
||||||
className={`${styles.navbarNav} justify-content-around`}
|
when: router.query.when || 'day'
|
||||||
activeKey={router.asPath}
|
}}
|
||||||
>
|
>
|
||||||
<Nav.Item>
|
<div className='text-muted font-weight-bold my-3 d-flex align-items-center'>
|
||||||
<Link href='/users/week' passHref>
|
user analytics for
|
||||||
<Nav.Link
|
<Select
|
||||||
className={styles.navLink}
|
groupClassName='mb-0 ml-2'
|
||||||
>
|
className='w-auto'
|
||||||
week
|
name='when'
|
||||||
</Nav.Link>
|
size='sm'
|
||||||
</Link>
|
items={['day', 'week', 'month', 'year', 'forever']}
|
||||||
</Nav.Item>
|
onChange={(formik, e) => router.push(`/users/${e.target.value}`)}
|
||||||
<Nav.Item>
|
/>
|
||||||
<Link href='/users/forever' passHref>
|
</div>
|
||||||
<Nav.Link
|
</Form>
|
||||||
className={styles.navLink}
|
|
||||||
>
|
|
||||||
forever
|
|
||||||
</Nav.Link>
|
|
||||||
</Link>
|
|
||||||
</Nav.Item>
|
|
||||||
</Nav>
|
|
||||||
</Navbar>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ export default function UserList ({ users }) {
|
|||||||
{abbrNum(user.ncomments)} comments
|
{abbrNum(user.ncomments)} comments
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
{user.referrals > 0 && <span> \ {abbrNum(user.referrals)} referrals</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
161
components/when-charts.js
Normal file
161
components/when-charts.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area, ComposedChart, Bar } from 'recharts'
|
||||||
|
import { abbrNum } from '../lib/format'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
const dateFormatter = when => {
|
||||||
|
return timeStr => {
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
switch (when) {
|
||||||
|
case 'week':
|
||||||
|
case 'month':
|
||||||
|
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
|
||||||
|
case 'year':
|
||||||
|
case 'forever':
|
||||||
|
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
|
||||||
|
default:
|
||||||
|
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function xAxisName (when) {
|
||||||
|
switch (when) {
|
||||||
|
case 'week':
|
||||||
|
case 'month':
|
||||||
|
return 'days'
|
||||||
|
case 'year':
|
||||||
|
case 'forever':
|
||||||
|
return 'months'
|
||||||
|
default:
|
||||||
|
return 'hours'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformData = data => {
|
||||||
|
return data.map(entry => {
|
||||||
|
const obj = { time: entry.time }
|
||||||
|
entry.data.forEach(entry1 => {
|
||||||
|
obj[entry1.name] = entry1.value
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--secondary)',
|
||||||
|
'var(--info)',
|
||||||
|
'var(--success)',
|
||||||
|
'var(--boost)',
|
||||||
|
'var(--theme-grey)',
|
||||||
|
'var(--danger)'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function WhenAreaChart ({ data }) {
|
||||||
|
const router = useRouter()
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// transform data into expected shape
|
||||||
|
data = transformData(data)
|
||||||
|
// need to grab when
|
||||||
|
const when = router.query.when
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||||
|
<AreaChart
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||||
|
tick={{ fill: 'var(--theme-grey)' }}
|
||||||
|
/>
|
||||||
|
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||||
|
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||||
|
<Legend />
|
||||||
|
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||||
|
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhenLineChart ({ data }) {
|
||||||
|
const router = useRouter()
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// transform data into expected shape
|
||||||
|
data = transformData(data)
|
||||||
|
// need to grab when
|
||||||
|
const when = router.query.when
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||||
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||||
|
tick={{ fill: 'var(--theme-grey)' }}
|
||||||
|
/>
|
||||||
|
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||||
|
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||||
|
<Legend />
|
||||||
|
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||||
|
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) {
|
||||||
|
const router = useRouter()
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// transform data into expected shape
|
||||||
|
data = transformData(data)
|
||||||
|
// need to grab when
|
||||||
|
const when = router.query.when
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||||
|
<ComposedChart
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||||
|
tick={{ fill: 'var(--theme-grey)' }}
|
||||||
|
/>
|
||||||
|
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||||
|
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||||
|
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||||
|
<Legend />
|
||||||
|
{barNames?.map((v, i) =>
|
||||||
|
<Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--info)' fill='var(--info)' />)}
|
||||||
|
{areaNames?.map((v, i) =>
|
||||||
|
<Area yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||||
|
{lineNames?.map((v, i) =>
|
||||||
|
<Line yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
@ -56,8 +56,8 @@ export const ITEM_FIELDS = gql`
|
|||||||
export const ITEMS = gql`
|
export const ITEMS = gql`
|
||||||
${ITEM_FIELDS}
|
${ITEM_FIELDS}
|
||||||
|
|
||||||
query items($sub: String, $sort: String, $cursor: String, $name: String, $within: String) {
|
query items($sub: String, $sort: String, $type: String, $cursor: String, $name: String, $within: String) {
|
||||||
items(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
|
items(sub: $sub, sort: $sort, type: $type, cursor: $cursor, name: $name, within: $within) {
|
||||||
cursor
|
cursor
|
||||||
items {
|
items {
|
||||||
...ItemFields
|
...ItemFields
|
||||||
|
@ -37,6 +37,9 @@ export const NOTIFICATIONS = gql`
|
|||||||
tips
|
tips
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
... on Referral {
|
||||||
|
sortTime
|
||||||
|
}
|
||||||
... on Reply {
|
... on Reply {
|
||||||
sortTime
|
sortTime
|
||||||
item {
|
item {
|
||||||
|
@ -3,36 +3,6 @@ import { COMMENT_FIELDS } from './comments'
|
|||||||
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
|
||||||
|
|
||||||
export const ME = gql`
|
export const ME = gql`
|
||||||
{
|
|
||||||
me {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
sats
|
|
||||||
stacked
|
|
||||||
freePosts
|
|
||||||
freeComments
|
|
||||||
hasNewNotes
|
|
||||||
tipDefault
|
|
||||||
fiatCurrency
|
|
||||||
bioId
|
|
||||||
hasInvites
|
|
||||||
upvotePopover
|
|
||||||
tipPopover
|
|
||||||
noteItemSats
|
|
||||||
noteEarning
|
|
||||||
noteAllDescendants
|
|
||||||
noteMentions
|
|
||||||
noteDeposits
|
|
||||||
noteInvites
|
|
||||||
noteJobIndicator
|
|
||||||
hideInvoiceDesc
|
|
||||||
wildWestMode
|
|
||||||
greeterMode
|
|
||||||
lastCheckedJobs
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
export const ME_SSR = gql`
|
|
||||||
{
|
{
|
||||||
me {
|
me {
|
||||||
id
|
id
|
||||||
@ -42,6 +12,7 @@ export const ME_SSR = gql`
|
|||||||
freePosts
|
freePosts
|
||||||
freeComments
|
freeComments
|
||||||
tipDefault
|
tipDefault
|
||||||
|
turboTipping
|
||||||
fiatCurrency
|
fiatCurrency
|
||||||
bioId
|
bioId
|
||||||
upvotePopover
|
upvotePopover
|
||||||
@ -54,6 +25,7 @@ export const ME_SSR = gql`
|
|||||||
noteInvites
|
noteInvites
|
||||||
noteJobIndicator
|
noteJobIndicator
|
||||||
hideInvoiceDesc
|
hideInvoiceDesc
|
||||||
|
hideFromTopUsers
|
||||||
wildWestMode
|
wildWestMode
|
||||||
greeterMode
|
greeterMode
|
||||||
lastCheckedJobs
|
lastCheckedJobs
|
||||||
@ -63,6 +35,7 @@ export const ME_SSR = gql`
|
|||||||
export const SETTINGS_FIELDS = gql`
|
export const SETTINGS_FIELDS = gql`
|
||||||
fragment SettingsFields on User {
|
fragment SettingsFields on User {
|
||||||
tipDefault
|
tipDefault
|
||||||
|
turboTipping
|
||||||
fiatCurrency
|
fiatCurrency
|
||||||
noteItemSats
|
noteItemSats
|
||||||
noteEarning
|
noteEarning
|
||||||
@ -72,6 +45,9 @@ export const SETTINGS_FIELDS = gql`
|
|||||||
noteInvites
|
noteInvites
|
||||||
noteJobIndicator
|
noteJobIndicator
|
||||||
hideInvoiceDesc
|
hideInvoiceDesc
|
||||||
|
hideFromTopUsers
|
||||||
|
nostrPubkey
|
||||||
|
nostrRelays
|
||||||
wildWestMode
|
wildWestMode
|
||||||
greeterMode
|
greeterMode
|
||||||
authMethods {
|
authMethods {
|
||||||
@ -93,15 +69,15 @@ ${SETTINGS_FIELDS}
|
|||||||
export const SET_SETTINGS =
|
export const SET_SETTINGS =
|
||||||
gql`
|
gql`
|
||||||
${SETTINGS_FIELDS}
|
${SETTINGS_FIELDS}
|
||||||
mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
|
mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!,
|
||||||
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
||||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!,
|
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!,
|
||||||
$wildWestMode: Boolean!, $greeterMode: Boolean!) {
|
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) {
|
||||||
setSettings(tipDefault: $tipDefault, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats,
|
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
|
||||||
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
||||||
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
||||||
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, wildWestMode: $wildWestMode,
|
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers,
|
||||||
greeterMode: $greeterMode) {
|
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) {
|
||||||
...SettingsFields
|
...SettingsFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,6 +137,7 @@ export const TOP_USERS = gql`
|
|||||||
spent(when: $when)
|
spent(when: $when)
|
||||||
ncomments(when: $when)
|
ncomments(when: $when)
|
||||||
nitems(when: $when)
|
nitems(when: $when)
|
||||||
|
referrals(when: $when)
|
||||||
}
|
}
|
||||||
cursor
|
cursor
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ export const INVOICE = gql`
|
|||||||
invoice(id: $id) {
|
invoice(id: $id) {
|
||||||
id
|
id
|
||||||
bolt11
|
bolt11
|
||||||
msatsReceived
|
satsReceived
|
||||||
cancelled
|
cancelled
|
||||||
confirmedAt
|
confirmedAt
|
||||||
expiresAt
|
expiresAt
|
||||||
@ -40,8 +40,8 @@ export const WALLET_HISTORY = gql`
|
|||||||
factId
|
factId
|
||||||
type
|
type
|
||||||
createdAt
|
createdAt
|
||||||
msats
|
sats
|
||||||
msatsFee
|
satsFee
|
||||||
status
|
status
|
||||||
type
|
type
|
||||||
description
|
description
|
||||||
|
461
lexical/nodes/image.js
Normal file
461
lexical/nodes/image.js
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
34
lexical/plugins/autolink.js
Normal file
34
lexical/plugins/autolink.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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} />
|
||||||
|
}
|
252
lexical/plugins/image-insert.js
Normal file
252
lexical/plugins/image-insert.js
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
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 * as Yup from 'yup'
|
||||||
|
import { ensureProtocol, URL_REGEXP } from '../../lib/url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
$createImageNode,
|
||||||
|
$isImageNode,
|
||||||
|
ImageNode
|
||||||
|
} from '../nodes/image'
|
||||||
|
import { Form, Input, SubmitButton } from '../../components/form'
|
||||||
|
import styles from '../styles.module.css'
|
||||||
|
|
||||||
|
const getDOMSelection = (targetWindow) =>
|
||||||
|
typeof window !== 'undefined' ? (targetWindow || window).getSelection() : null
|
||||||
|
|
||||||
|
export const INSERT_IMAGE_COMMAND = createCommand('INSERT_IMAGE_COMMAND')
|
||||||
|
|
||||||
|
const LinkSchema = Yup.object({
|
||||||
|
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ImageInsertModal ({ onClose, editor }) {
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
url: '',
|
||||||
|
alt: ''
|
||||||
|
}}
|
||||||
|
schema={LinkSchema}
|
||||||
|
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
|
||||||
|
}
|
134
lexical/plugins/link-insert.js
Normal file
134
lexical/plugins/link-insert.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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 * as Yup from 'yup'
|
||||||
|
import { Form, Input, SubmitButton } from '../../components/form'
|
||||||
|
import { ensureProtocol, URL_REGEXP } from '../../lib/url'
|
||||||
|
import { getSelectedNode } from '../utils/selected-node'
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkSchema = Yup.object({
|
||||||
|
text: Yup.string().required('required'),
|
||||||
|
url: Yup.string().matches(URL_REGEXP, 'invalid url').required('required')
|
||||||
|
})
|
||||||
|
|
||||||
|
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={LinkSchema}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
232
lexical/plugins/link-tooltip.js
Normal file
232
lexical/plugins/link-tooltip.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
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 })
|
||||||
|
}
|
68
lexical/plugins/list-max-indent.js
Normal file
68
lexical/plugins/list-max-indent.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list'
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
|
import {
|
||||||
|
$getSelection,
|
||||||
|
$isElementNode,
|
||||||
|
$isRangeSelection,
|
||||||
|
INDENT_CONTENT_COMMAND,
|
||||||
|
COMMAND_PRIORITY_HIGH
|
||||||
|
} from 'lexical'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
function getElementNodesInSelection (selection) {
|
||||||
|
const nodesInSelection = selection.getNodes()
|
||||||
|
|
||||||
|
if (nodesInSelection.length === 0) {
|
||||||
|
return new Set([
|
||||||
|
selection.anchor.getNode().getParentOrThrow(),
|
||||||
|
selection.focus.getNode().getParentOrThrow()
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIndentPermitted (maxDepth) {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if (!$isRangeSelection(selection)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementNodesInSelection = getElementNodesInSelection(selection)
|
||||||
|
|
||||||
|
let totalDepth = 0
|
||||||
|
|
||||||
|
for (const elementNode of elementNodesInSelection) {
|
||||||
|
if ($isListNode(elementNode)) {
|
||||||
|
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth)
|
||||||
|
} else if ($isListItemNode(elementNode)) {
|
||||||
|
const parent = elementNode.getParent()
|
||||||
|
if (!$isListNode(parent)) {
|
||||||
|
throw new Error(
|
||||||
|
'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDepth <= maxDepth
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListMaxIndentLevelPlugin ({ maxDepth }) {
|
||||||
|
const [editor] = useLexicalComposerContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerCommand(
|
||||||
|
INDENT_CONTENT_COMMAND,
|
||||||
|
() => !isIndentPermitted(maxDepth ?? 7),
|
||||||
|
COMMAND_PRIORITY_HIGH
|
||||||
|
)
|
||||||
|
}, [editor, maxDepth])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
383
lexical/plugins/toolbar.js
Normal file
383
lexical/plugins/toolbar.js
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
SELECTION_CHANGE_COMMAND,
|
||||||
|
FORMAT_TEXT_COMMAND,
|
||||||
|
INDENT_CONTENT_COMMAND,
|
||||||
|
OUTDENT_CONTENT_COMMAND,
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
$createParagraphNode
|
||||||
|
} from 'lexical'
|
||||||
|
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||||||
|
import {
|
||||||
|
$wrapNodes
|
||||||
|
} from '@lexical/selection'
|
||||||
|
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
|
||||||
|
import {
|
||||||
|
INSERT_ORDERED_LIST_COMMAND,
|
||||||
|
INSERT_UNORDERED_LIST_COMMAND,
|
||||||
|
REMOVE_LIST_COMMAND,
|
||||||
|
$isListNode,
|
||||||
|
ListNode
|
||||||
|
} from '@lexical/list'
|
||||||
|
import {
|
||||||
|
$createHeadingNode,
|
||||||
|
$createQuoteNode,
|
||||||
|
$isHeadingNode
|
||||||
|
} from '@lexical/rich-text'
|
||||||
|
// import {
|
||||||
|
// $createCodeNode
|
||||||
|
// } from '@lexical/code'
|
||||||
|
import BoldIcon from '../../svgs/bold.svg'
|
||||||
|
import ItalicIcon from '../../svgs/italic.svg'
|
||||||
|
// import StrikethroughIcon from '../../svgs/strikethrough.svg'
|
||||||
|
import LinkIcon from '../../svgs/link.svg'
|
||||||
|
import ListOrderedIcon from '../../svgs/list-ordered.svg'
|
||||||
|
import ListUnorderedIcon from '../../svgs/list-unordered.svg'
|
||||||
|
import IndentIcon from '../../svgs/indent-increase.svg'
|
||||||
|
import OutdentIcon from '../../svgs/indent-decrease.svg'
|
||||||
|
import ImageIcon from '../../svgs/image-line.svg'
|
||||||
|
import FontSizeIcon from '../../svgs/font-size-2.svg'
|
||||||
|
import QuoteIcon from '../../svgs/double-quotes-r.svg'
|
||||||
|
// import CodeIcon from '../../svgs/code-line.svg'
|
||||||
|
// import CodeBoxIcon from '../../svgs/code-box-line.svg'
|
||||||
|
import ArrowDownIcon from '../../svgs/arrow-down-s-fill.svg'
|
||||||
|
import CheckIcon from '../../svgs/check-line.svg'
|
||||||
|
|
||||||
|
import styles from '../styles.module.css'
|
||||||
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import { useLinkInsert } from './link-insert'
|
||||||
|
import { getSelectedNode } from '../utils/selected-node'
|
||||||
|
import { getLinkFromSelection } from '../utils/link-from-selection'
|
||||||
|
import { ImageInsertModal } from './image-insert'
|
||||||
|
import useModal from '../utils/modal'
|
||||||
|
|
||||||
|
const LowPriority = 1
|
||||||
|
|
||||||
|
function Divider () {
|
||||||
|
return <div className={styles.divider} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function FontSizeDropdown ({
|
||||||
|
editor,
|
||||||
|
blockType
|
||||||
|
}) {
|
||||||
|
const formatParagraph = () => {
|
||||||
|
if (blockType !== 'paragraph') {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$wrapNodes(selection, () => $createParagraphNode())
|
||||||
|
}
|
||||||
|
setTimeout(() => editor.focus(), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLargeHeading = () => {
|
||||||
|
if (blockType !== 'h1') {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$wrapNodes(selection, () => $createHeadingNode('h1'))
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => editor.focus(), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSmallHeading = () => {
|
||||||
|
if (blockType !== 'h2') {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$wrapNodes(selection, () => $createHeadingNode('h2'))
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => editor.focus(), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown className='pointer' as='span'>
|
||||||
|
<Dropdown.Toggle
|
||||||
|
id='dropdown-basic'
|
||||||
|
as='button' className={styles.toolbarItem} aria-label='Font size'
|
||||||
|
>
|
||||||
|
<FontSizeIcon />
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item as='button' className={`${styles.paragraph} my-0`} onClick={formatParagraph}>
|
||||||
|
<CheckIcon className={`mr-1 ${blockType === 'paragraph' ? 'fill-grey' : 'invisible'}`} />
|
||||||
|
<span className={styles.text}>normal</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item as='button' className={`${styles.heading2} my-0`} onClick={formatSmallHeading}>
|
||||||
|
<CheckIcon className={`mr-1 ${['h2', 'h3', 'h4', 'h5', 'h6'].includes(blockType) ? 'fill-grey' : 'invisible'}`} />
|
||||||
|
<span className={styles.text}>subheading</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item as='button' className={`${styles.heading1} my-0`} onClick={formatLargeHeading}>
|
||||||
|
<CheckIcon className={`mr-1 ${blockType === 'h1' ? 'fill-grey' : 'invisible'}`} />
|
||||||
|
<span className={styles.text}>heading</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolbarPlugin () {
|
||||||
|
const [editor] = useLexicalComposerContext()
|
||||||
|
const { setLink } = useLinkInsert()
|
||||||
|
const toolbarRef = useRef(null)
|
||||||
|
const [blockType, setBlockType] = useState('paragraph')
|
||||||
|
const [isLink, setIsLink] = useState(false)
|
||||||
|
const [isBold, setIsBold] = useState(false)
|
||||||
|
const [isItalic, setIsItalic] = useState(false)
|
||||||
|
// const [isStrikethrough, setIsStrikethrough] = useState(false)
|
||||||
|
// const [isCode, setIsCode] = useState(false)
|
||||||
|
const [modal, showModal] = useModal()
|
||||||
|
|
||||||
|
const updateToolbar = useCallback(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const anchorNode = selection.anchor.getNode()
|
||||||
|
const element =
|
||||||
|
anchorNode.getKey() === 'root'
|
||||||
|
? anchorNode
|
||||||
|
: anchorNode.getTopLevelElementOrThrow()
|
||||||
|
const elementKey = element.getKey()
|
||||||
|
const elementDOM = editor.getElementByKey(elementKey)
|
||||||
|
if (elementDOM !== null) {
|
||||||
|
if ($isListNode(element)) {
|
||||||
|
const parentList = $getNearestNodeOfType(anchorNode, ListNode)
|
||||||
|
const type = parentList ? parentList.getTag() : element.getTag()
|
||||||
|
setBlockType(type)
|
||||||
|
} else {
|
||||||
|
const type = $isHeadingNode(element)
|
||||||
|
? element.getTag()
|
||||||
|
: element.getType()
|
||||||
|
setBlockType(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update text format
|
||||||
|
setIsBold(selection.hasFormat('bold'))
|
||||||
|
setIsItalic(selection.hasFormat('italic'))
|
||||||
|
// setIsStrikethrough(selection.hasFormat('strikethrough'))
|
||||||
|
// setIsCode(selection.hasFormat('code'))
|
||||||
|
|
||||||
|
// Update links
|
||||||
|
const node = getSelectedNode(selection)
|
||||||
|
const parent = node.getParent()
|
||||||
|
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||||
|
setIsLink(true)
|
||||||
|
} else {
|
||||||
|
setIsLink(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return mergeRegister(
|
||||||
|
editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
updateToolbar()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
editor.registerCommand(
|
||||||
|
SELECTION_CHANGE_COMMAND,
|
||||||
|
(_payload, newEditor) => {
|
||||||
|
updateToolbar()
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
LowPriority
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [editor, updateToolbar])
|
||||||
|
|
||||||
|
const insertLink = useCallback(() => {
|
||||||
|
if (isLink) {
|
||||||
|
// unlink it
|
||||||
|
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||||
|
} else {
|
||||||
|
editor.update(() => {
|
||||||
|
setLink(getLinkFromSelection())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [editor, isLink])
|
||||||
|
|
||||||
|
const formatBulletList = () => {
|
||||||
|
if (blockType !== 'ul') {
|
||||||
|
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)
|
||||||
|
} else {
|
||||||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumberedList = () => {
|
||||||
|
if (blockType !== 'ol') {
|
||||||
|
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)
|
||||||
|
} else {
|
||||||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatQuote = () => {
|
||||||
|
if (blockType !== 'quote') {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$wrapNodes(selection, () => $createQuoteNode())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection()
|
||||||
|
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$wrapNodes(selection, () => $createParagraphNode())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const formatCode = () => {
|
||||||
|
// if (blockType !== 'code') {
|
||||||
|
// editor.update(() => {
|
||||||
|
// const selection = $getSelection()
|
||||||
|
|
||||||
|
// if ($isRangeSelection(selection)) {
|
||||||
|
// $wrapNodes(selection, () => {
|
||||||
|
// const node = $createCodeNode()
|
||||||
|
// node.setLanguage('plain')
|
||||||
|
// return node
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toolbar} ref={toolbarRef}>
|
||||||
|
<FontSizeDropdown editor={editor} blockType={blockType} />
|
||||||
|
<Divider />
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`}
|
||||||
|
aria-label='Format Bold'
|
||||||
|
>
|
||||||
|
<BoldIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`}
|
||||||
|
aria-label='Format Italics'
|
||||||
|
>
|
||||||
|
<ItalicIcon />
|
||||||
|
</button>
|
||||||
|
<Divider />
|
||||||
|
<button
|
||||||
|
onClick={formatBulletList}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ul' ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<ListUnorderedIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatNumberedList}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ol' ? styles.active : ''}`}
|
||||||
|
aria-label='Insert numbered list'
|
||||||
|
>
|
||||||
|
<ListOrderedIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||||
|
aria-label='Indent'
|
||||||
|
>
|
||||||
|
<IndentIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||||
|
aria-label='Outdent'
|
||||||
|
>
|
||||||
|
<OutdentIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatQuote}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'quote' ? styles.active : ''}`}
|
||||||
|
aria-label='Insert Quote'
|
||||||
|
>
|
||||||
|
<QuoteIcon />
|
||||||
|
</button>
|
||||||
|
{/* <Divider /> */}
|
||||||
|
{/* <button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
`${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
aria-label='Format Strikethrough'
|
||||||
|
>
|
||||||
|
<StrikethroughIcon />
|
||||||
|
</button> */}
|
||||||
|
{/* <button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`}
|
||||||
|
aria-label='Insert Code'
|
||||||
|
>
|
||||||
|
<CodeIcon />
|
||||||
|
</button> */}
|
||||||
|
{/* <button
|
||||||
|
onClick={formatCode}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'code' ? styles.active : ''}`}
|
||||||
|
aria-label='Insert Code'
|
||||||
|
>
|
||||||
|
<CodeBoxIcon />
|
||||||
|
</button> */}
|
||||||
|
<Divider />
|
||||||
|
<button
|
||||||
|
onClick={insertLink}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isLink ? styles.active : ''}`}
|
||||||
|
aria-label='Insert Link'
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
showModal((onClose) => (
|
||||||
|
<ImageInsertModal
|
||||||
|
editor={editor}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||||||
|
aria-label='Insert Image'
|
||||||
|
>
|
||||||
|
<ImageIcon />
|
||||||
|
</button>
|
||||||
|
{modal}
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
256
lexical/styles.module.css
Normal file
256
lexical/styles.module.css
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/* editor */
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorContainer {
|
||||||
|
margin: 20px auto 20px auto;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--theme-color);
|
||||||
|
position: relative;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: left;
|
||||||
|
border-top-left-radius: .4rem;
|
||||||
|
border-top-right-radius: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInput>hr {
|
||||||
|
border-top: 1px solid var(--theme-clickToContextColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorInput {
|
||||||
|
min-height: 150px;
|
||||||
|
resize: auto;
|
||||||
|
font-size: 15px;
|
||||||
|
caret-color: var(--theme-color);
|
||||||
|
background-color: var(--theme-body);
|
||||||
|
position: relative;
|
||||||
|
tab-size: 1;
|
||||||
|
outline: 0;
|
||||||
|
padding: 15px 10px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-bottom-left-radius: .4rem;
|
||||||
|
border-bottom-right-radius: .4rem;
|
||||||
|
/* border-top: 0px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPlaceholder {
|
||||||
|
color: var(--theme-grey);
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
top: 15px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
user-select: none;
|
||||||
|
display: inline-block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* blocks */
|
||||||
|
|
||||||
|
.image {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--theme-quoteColor);
|
||||||
|
border-left-color: var(--theme-quoteBar);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-left-style: solid;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--theme-color);
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading2 {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--theme-navLink);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background-color: rgb(240, 242, 245);
|
||||||
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
|
display: block;
|
||||||
|
padding: 8px 8px 8px 52px;
|
||||||
|
line-height: 1.53;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
tab-size: 2;
|
||||||
|
/* white-space: pre; */
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inline blocks */
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--theme-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* lists */
|
||||||
|
|
||||||
|
.listOl {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listUl {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem {
|
||||||
|
margin: 8px 32px 8px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedListItem {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
|
||||||
|
.textBold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textItalic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textUnderline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textStrikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textUnderlineStrikethrough {
|
||||||
|
text-decoration: underline line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textCode {
|
||||||
|
background-color: rgb(240, 242, 245);
|
||||||
|
padding: 1px 0.25rem;
|
||||||
|
font-family: Menlo, Consolas, Monaco, monospace;
|
||||||
|
font-size: 94%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toolbar */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--theme-toolbar);
|
||||||
|
padding: 4px;
|
||||||
|
border-top-left-radius: .4rem;
|
||||||
|
border-top-right-radius: .4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem {
|
||||||
|
border: 0;
|
||||||
|
display: flex;
|
||||||
|
background: none;
|
||||||
|
border-radius: .4rem;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem.spaced {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem svg {
|
||||||
|
background-size: contain;
|
||||||
|
display: inline-block;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
margin-top: 2px;
|
||||||
|
vertical-align: -0.25em;
|
||||||
|
display: flex;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem:disabled svg {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem.active {
|
||||||
|
background-color: var(--theme-toolbarActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarItem.active svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .toolbarItem:hover:not([disabled]) {
|
||||||
|
background-color: var(--theme-toolbarHover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .divider {
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--theme-borderColor);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .toolbarItem svg {
|
||||||
|
fill: var(--theme-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkTooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 0.7875rem;
|
||||||
|
opacity: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipUrl {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
37
lexical/theme.js
Normal file
37
lexical/theme.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import styles from './styles.module.css'
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
paragraph: styles.paragraph,
|
||||||
|
quote: styles.quote,
|
||||||
|
heading: {
|
||||||
|
h1: styles.heading1,
|
||||||
|
h2: styles.heading2,
|
||||||
|
h3: styles.heading2,
|
||||||
|
h4: styles.heading2,
|
||||||
|
h5: styles.heading2
|
||||||
|
},
|
||||||
|
image: styles.image,
|
||||||
|
link: styles.link,
|
||||||
|
code: styles.code,
|
||||||
|
hr: styles.hr,
|
||||||
|
list: {
|
||||||
|
nested: {
|
||||||
|
listitem: styles.nestedListItem
|
||||||
|
},
|
||||||
|
ol: styles.listOl,
|
||||||
|
ul: styles.listUl,
|
||||||
|
listitem: styles.listItem
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
bold: styles.textBold,
|
||||||
|
italic: styles.textItalic,
|
||||||
|
// overflowed: 'editor-text-overflowed',
|
||||||
|
// hashtag: 'editor-text-hashtag',
|
||||||
|
underline: styles.textUnderline,
|
||||||
|
strikethrough: styles.textStrikethrough,
|
||||||
|
underlineStrikethrough: styles.underlineStrikethrough,
|
||||||
|
code: styles.textCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default theme
|
55
lexical/utils/image-markdown-transformer.js
Normal file
55
lexical/utils/image-markdown-transformer.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
$createImageNode,
|
||||||
|
$isImageNode,
|
||||||
|
ImageNode
|
||||||
|
} from '../nodes/image'
|
||||||
|
import {
|
||||||
|
$createHorizontalRuleNode,
|
||||||
|
$isHorizontalRuleNode,
|
||||||
|
HorizontalRuleNode
|
||||||
|
} from '@lexical/react/LexicalHorizontalRuleNode'
|
||||||
|
import { TRANSFORMERS } from '@lexical/markdown'
|
||||||
|
|
||||||
|
export const IMAGE = {
|
||||||
|
dependencies: [ImageNode],
|
||||||
|
export: (node, exportChildren, exportFormat) => {
|
||||||
|
if (!$isImageNode(node)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `![${node.getAltText()}](${node.getSrc()})`
|
||||||
|
},
|
||||||
|
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
|
||||||
|
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
|
||||||
|
replace: (textNode, match) => {
|
||||||
|
const [, altText, src] = match
|
||||||
|
const imageNode = $createImageNode({ altText, src })
|
||||||
|
textNode.replace(imageNode)
|
||||||
|
},
|
||||||
|
trigger: ')',
|
||||||
|
type: 'text-match'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HR = {
|
||||||
|
dependencies: [HorizontalRuleNode],
|
||||||
|
export: (node) => {
|
||||||
|
return $isHorizontalRuleNode(node) ? '***' : null
|
||||||
|
},
|
||||||
|
regExp: /^(-{3,}|\*{3,}|_{3,})\s?$/,
|
||||||
|
replace: (parentNode, _1, _2, isImport) => {
|
||||||
|
const line = $createHorizontalRuleNode()
|
||||||
|
|
||||||
|
// TODO: Get rid of isImport flag
|
||||||
|
if (isImport || parentNode.getNextSibling() != null) {
|
||||||
|
parentNode.replace(line)
|
||||||
|
} else {
|
||||||
|
parentNode.insertBefore(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
line.selectNext()
|
||||||
|
},
|
||||||
|
type: 'element'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SN_TRANSFORMERS = [
|
||||||
|
HR, IMAGE, ...TRANSFORMERS
|
||||||
|
]
|
24
lexical/utils/link-from-selection.js
Normal file
24
lexical/utils/link-from-selection.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { $getSelection, $getTextContent, $isRangeSelection } from 'lexical'
|
||||||
|
import { getSelectedNode } from './selected-node'
|
||||||
|
import { $isLinkNode } from '@lexical/link'
|
||||||
|
|
||||||
|
export function getLinkFromSelection () {
|
||||||
|
const selection = $getSelection()
|
||||||
|
let url = ''
|
||||||
|
let text = ''
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const node = getSelectedNode(selection)
|
||||||
|
const parent = node.getParent()
|
||||||
|
if ($isLinkNode(parent)) {
|
||||||
|
url = parent.getURL()
|
||||||
|
text = parent.getTextContent()
|
||||||
|
} else if ($isLinkNode(node)) {
|
||||||
|
url = node.getURL()
|
||||||
|
text = node.getTextContent()
|
||||||
|
} else {
|
||||||
|
url = ''
|
||||||
|
text = $getTextContent(selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { url, text }
|
||||||
|
}
|
34
lexical/utils/modal.js
Normal file
34
lexical/utils/modal.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Modal } from 'react-bootstrap'
|
||||||
|
|
||||||
|
export default function useModal () {
|
||||||
|
const [modalContent, setModalContent] = useState(null)
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
setModalContent(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const modal = useMemo(() => {
|
||||||
|
if (modalContent === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal onHide={onClose} show={!!modalContent}>
|
||||||
|
<div className='modal-close' onClick={onClose}>X</div>
|
||||||
|
<Modal.Body>
|
||||||
|
{modalContent}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}, [modalContent, onClose])
|
||||||
|
|
||||||
|
const showModal = useCallback(
|
||||||
|
(getContent) => {
|
||||||
|
setModalContent(getContent(onClose))
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [modal, showModal]
|
||||||
|
}
|
17
lexical/utils/selected-node.js
Normal file
17
lexical/utils/selected-node.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { $isAtNodeEnd } from '@lexical/selection'
|
||||||
|
|
||||||
|
export function getSelectedNode (selection) {
|
||||||
|
const anchor = selection.anchor
|
||||||
|
const focus = selection.focus
|
||||||
|
const anchorNode = selection.anchor.getNode()
|
||||||
|
const focusNode = selection.focus.getNode()
|
||||||
|
if (anchorNode === focusNode) {
|
||||||
|
return anchorNode
|
||||||
|
}
|
||||||
|
const isBackward = selection.isBackward()
|
||||||
|
if (isBackward) {
|
||||||
|
return $isAtNodeEnd(focus) ? anchorNode : focusNode
|
||||||
|
} else {
|
||||||
|
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
|
||||||
|
}
|
||||||
|
}
|
41
lexical/utils/tooltip-position.js
Normal file
41
lexical/utils/tooltip-position.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const VERTICAL_GAP = 5
|
||||||
|
const HORIZONTAL_OFFSET = 5
|
||||||
|
|
||||||
|
export function setTooltipPosition (
|
||||||
|
targetRect,
|
||||||
|
floatingElem,
|
||||||
|
anchorElem,
|
||||||
|
verticalGap = VERTICAL_GAP,
|
||||||
|
horizontalOffset = HORIZONTAL_OFFSET
|
||||||
|
) {
|
||||||
|
const scrollerElem = anchorElem.parentElement
|
||||||
|
|
||||||
|
if (targetRect === null || !scrollerElem) {
|
||||||
|
floatingElem.style.opacity = '0'
|
||||||
|
floatingElem.style.transform = 'translate(-10000px, -10000px)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingElemRect = floatingElem.getBoundingClientRect()
|
||||||
|
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||||
|
const editorScrollerRect = scrollerElem.getBoundingClientRect()
|
||||||
|
|
||||||
|
let top = targetRect.top - floatingElemRect.height - verticalGap
|
||||||
|
let left = targetRect.left - horizontalOffset
|
||||||
|
|
||||||
|
top += floatingElemRect.height + targetRect.height + verticalGap * 2
|
||||||
|
|
||||||
|
if (left + floatingElemRect.width > editorScrollerRect.right) {
|
||||||
|
left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
top -= anchorElementRect.top
|
||||||
|
left -= anchorElementRect.left
|
||||||
|
|
||||||
|
if (top > 0 && left > 0) {
|
||||||
|
floatingElem.style.opacity = '1'
|
||||||
|
} else {
|
||||||
|
floatingElem.style.opacity = '0'
|
||||||
|
}
|
||||||
|
floatingElem.style.transform = `translate(${left}px, ${top}px)`
|
||||||
|
}
|
24
lexical/utils/url.js
Normal file
24
lexical/utils/url.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function sanitizeUrl (url) {
|
||||||
|
/** A pattern that matches safe URLs. */
|
||||||
|
const SAFE_URL_PATTERN =
|
||||||
|
/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi
|
||||||
|
|
||||||
|
/** A pattern that matches safe data URLs. */
|
||||||
|
const DATA_URL_PATTERN =
|
||||||
|
/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i
|
||||||
|
|
||||||
|
url = String(url).trim()
|
||||||
|
|
||||||
|
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url
|
||||||
|
|
||||||
|
return 'https://'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: https://stackoverflow.com/a/8234912/2013580
|
||||||
|
const urlRegExp =
|
||||||
|
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/
|
||||||
|
export function validateUrl (url) {
|
||||||
|
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
|
||||||
|
// Maybe show a dialog where they user can type the URL before inserting it.
|
||||||
|
return url === 'https://' || urlRegExp.test(url)
|
||||||
|
}
|
@ -1,11 +1,11 @@
|
|||||||
import { ApolloClient, InMemoryCache, from, HttpLink } from '@apollo/client'
|
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
|
||||||
import { decodeCursor, LIMIT } from './cursor'
|
import { decodeCursor, LIMIT } from './cursor'
|
||||||
import { RetryLink } from '@apollo/client/link/retry'
|
// import { RetryLink } from '@apollo/client/link/retry'
|
||||||
|
|
||||||
const additiveLink = from([
|
// const additiveLink = from([
|
||||||
new RetryLink(),
|
// new RetryLink(),
|
||||||
new HttpLink({ uri: '/api/graphql' })
|
// new HttpLink({ uri: '/api/graphql' })
|
||||||
])
|
// ])
|
||||||
|
|
||||||
function isFirstPage (cursor, existingThings) {
|
function isFirstPage (cursor, existingThings) {
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@ -19,8 +19,19 @@ function isFirstPage (cursor, existingThings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function getApolloClient () {
|
export default function getApolloClient () {
|
||||||
global.apolloClient ||= new ApolloClient({
|
if (typeof window === 'undefined') {
|
||||||
link: additiveLink,
|
const client = getClient(`${process.env.SELF_URL}/api/graphql`)
|
||||||
|
client.clearStore()
|
||||||
|
return client
|
||||||
|
} else {
|
||||||
|
global.apolloClient ||= getClient('/api/graphql')
|
||||||
|
return global.apolloClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient (uri) {
|
||||||
|
return new ApolloClient({
|
||||||
|
link: new HttpLink({ uri }),
|
||||||
cache: new InMemoryCache({
|
cache: new InMemoryCache({
|
||||||
typePolicies: {
|
typePolicies: {
|
||||||
Query: {
|
Query: {
|
||||||
@ -39,7 +50,7 @@ export default function getApolloClient () {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
keyArgs: ['sub', 'sort', 'name', 'within'],
|
keyArgs: ['sub', 'sort', 'type', 'name', 'within'],
|
||||||
merge (existing, incoming) {
|
merge (existing, incoming) {
|
||||||
if (isFirstPage(incoming.cursor, existing?.items)) {
|
if (isFirstPage(incoming.cursor, existing?.items)) {
|
||||||
return incoming
|
return incoming
|
||||||
@ -201,6 +212,4 @@ export default function getApolloClient () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return global.apolloClient
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export const NOFOLLOW_LIMIT = 1000
|
export const NOFOLLOW_LIMIT = 100
|
||||||
export const BOOST_MIN = 5000
|
export const BOOST_MIN = 5000
|
||||||
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
|
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
|
||||||
export const IMAGE_PIXELS_MAX = 35000000
|
export const IMAGE_PIXELS_MAX = 35000000
|
||||||
@ -16,3 +16,4 @@ export const ITEM_SPAM_INTERVAL = '10m'
|
|||||||
export const MAX_POLL_NUM_CHOICES = 10
|
export const MAX_POLL_NUM_CHOICES = 10
|
||||||
export const ITEM_FILTER_THRESHOLD = 1.2
|
export const ITEM_FILTER_THRESHOLD = 1.2
|
||||||
export const DONT_LIKE_THIS_COST = 1
|
export const DONT_LIKE_THIS_COST = 1
|
||||||
|
export const MAX_NOSTR_RELAY_NUM = 20
|
||||||
|
@ -9,3 +9,17 @@ export const abbrNum = n => {
|
|||||||
export const fixedDecimal = (n, f) => {
|
export const fixedDecimal = (n, f) => {
|
||||||
return Number.parseFloat(n).toFixed(f)
|
return Number.parseFloat(n).toFixed(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const msatsToSats = msats => {
|
||||||
|
if (msats === null || msats === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Number(BigInt(msats) / 1000n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const msatsToSatsDecimal = msats => {
|
||||||
|
if (msats === null || msats === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return fixedDecimal(msats / 1000.0, 3)
|
||||||
|
}
|
||||||
|
@ -5,6 +5,7 @@ export function ensureProtocol (value) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function removeTracking (value) {
|
export function removeTracking (value) {
|
||||||
const exprs = [
|
const exprs = [
|
||||||
// twitter URLs
|
// twitter URLs
|
||||||
@ -15,3 +16,9 @@ export function removeTracking (value) {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
export const WS_REGEXP = /^(wss?:\/\/)([0-9]{1,3}(?:\.[0-9]{1,3}){3}|(?=[^\/]{1,254}(?![^\/]))(?:(?=[a-zA-Z0-9-]{1,63}\.)(?:xn--+)?[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63})(:([0-9]{1,5}))?$/
|
||||||
|
18
middleware.js
Normal file
18
middleware.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export function middleware (request) {
|
||||||
|
const regex = /(\/.*)?\/r\/([\w_]+)/
|
||||||
|
const m = regex.exec(request.nextUrl.pathname)
|
||||||
|
|
||||||
|
const url = new URL(m[1] || '/', request.url)
|
||||||
|
url.search = request.nextUrl.search
|
||||||
|
url.hash = request.nextUrl.hash
|
||||||
|
|
||||||
|
const resp = NextResponse.redirect(url)
|
||||||
|
resp.cookies.set('sn_referrer', m[2])
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
|
||||||
|
}
|
@ -26,7 +26,7 @@ module.exports = withPlausibleProxy()({
|
|||||||
return Object.keys(RuntimeSources['stacker.news'])[0]
|
return Object.keys(RuntimeSources['stacker.news'])[0]
|
||||||
},
|
},
|
||||||
// Use the CDN in production and localhost for development.
|
// Use the CDN in production and localhost for development.
|
||||||
assetPrefix: isProd ? 'https://a.stacker.news' : '',
|
assetPrefix: isProd ? 'https://a.stacker.news' : undefined,
|
||||||
async headers () {
|
async headers () {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -42,6 +42,18 @@ module.exports = withPlausibleProxy()({
|
|||||||
value: 'public, max-age=31536000, immutable'
|
value: 'public, max-age=31536000, immutable'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/:slug*',
|
||||||
|
headers: [
|
||||||
|
...corsHeaders
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/api/lnauth',
|
||||||
|
headers: [
|
||||||
|
...corsHeaders
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -71,6 +83,10 @@ module.exports = withPlausibleProxy()({
|
|||||||
source: '/.well-known/lnurlp/:username',
|
source: '/.well-known/lnurlp/:username',
|
||||||
destination: '/api/lnurlp/:username'
|
destination: '/api/lnurlp/:username'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/nostr.json',
|
||||||
|
destination: '/api/nostr/nip05'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/~:sub',
|
source: '/~:sub',
|
||||||
destination: '/~/:sub'
|
destination: '/~/:sub'
|
||||||
|
6365
package-lock.json
generated
6365
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
82
package.json
82
package.json
@ -3,74 +3,81 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS='--trace-warnings --inspect' next dev",
|
"dev": "NODE_OPTIONS='--trace-warnings' next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"migrate": "prisma migrate deploy",
|
"migrate": "prisma migrate deploy",
|
||||||
"start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT"
|
"start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.4.15",
|
"@apollo/client": "^3.7.1",
|
||||||
"@opensearch-project/opensearch": "^1.0.2",
|
"@lexical/react": "^0.7.5",
|
||||||
"@prisma/client": "^2.25.0",
|
"@opensearch-project/opensearch": "^1.1.0",
|
||||||
"apollo-server-micro": "^2.21.2",
|
"@prisma/client": "^2.30.3",
|
||||||
|
"apollo-server-micro": "^3.11.1",
|
||||||
"async-retry": "^1.3.1",
|
"async-retry": "^1.3.1",
|
||||||
"aws-sdk": "^2.1056.0",
|
"aws-sdk": "^2.1248.0",
|
||||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"bolt11": "^1.3.4",
|
"bolt11": "^1.4.0",
|
||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.2",
|
||||||
|
"browserslist": "^4.21.4",
|
||||||
"clipboard-copy": "^4.0.1",
|
"clipboard-copy": "^4.0.1",
|
||||||
"cross-fetch": "^3.1.5",
|
"cross-fetch": "^3.1.5",
|
||||||
"domino": "^2.1.6",
|
"domino": "^2.1.6",
|
||||||
"formik": "^2.2.6",
|
"formik": "^2.2.6",
|
||||||
"github-slugger": "^1.4.0",
|
"github-slugger": "^1.5.0",
|
||||||
"graphql": "^15.5.0",
|
"graphql": "^15.8.0",
|
||||||
|
"graphql-tools": "^8.3.10",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
"ln-service": "^54.2.1",
|
"jquery": "^3.6.1",
|
||||||
|
"lexical": "^0.7.5",
|
||||||
|
"ln-service": "^54.2.6",
|
||||||
"mdast-util-find-and-replace": "^1.1.1",
|
"mdast-util-find-and-replace": "^1.1.1",
|
||||||
"mdast-util-from-markdown": "^1.2.0",
|
"mdast-util-from-markdown": "^1.2.0",
|
||||||
"mdast-util-to-string": "^3.1.0",
|
"mdast-util-to-string": "^3.1.0",
|
||||||
"next": "^11.1.2",
|
"micro": "^9.4.1",
|
||||||
"next-auth": "^3.29.3",
|
"next": "^12.3.2",
|
||||||
"next-plausible": "^2.1.3",
|
"next-auth": "^3.29.10",
|
||||||
"next-seo": "^4.24.0",
|
"next-plausible": "^3.6.4",
|
||||||
"nextjs-progressbar": "^0.0.13",
|
"next-seo": "^4.29.0",
|
||||||
|
"nextjs-progressbar": "0.0.16",
|
||||||
"node-s3-url-encode": "^0.0.4",
|
"node-s3-url-encode": "^0.0.4",
|
||||||
"page-metadata-parser": "^1.1.4",
|
"page-metadata-parser": "^1.1.4",
|
||||||
"pageres": "^6.3.0",
|
"pageres": "^7.1.0",
|
||||||
"pg-boss": "^7.0.2",
|
"pg-boss": "^7.4.0",
|
||||||
"prisma": "^2.25.0",
|
"popper.js": "^1.16.1",
|
||||||
"qrcode.react": "^1.0.1",
|
"prisma": "^2.30.3",
|
||||||
"react": "^17.0.1",
|
"qrcode.react": "^3.1.0",
|
||||||
|
"react": "^17.0.2",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-bootstrap": "^1.5.2",
|
"react-bootstrap": "^1.6.6",
|
||||||
"react-countdown": "^2.3.2",
|
"react-countdown": "^2.3.3",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-longpressable": "^1.1.1",
|
"react-longpressable": "^1.1.1",
|
||||||
"react-markdown": "^8.0.0",
|
"react-markdown": "^8.0.3",
|
||||||
"react-string-replace": "^0.4.4",
|
"react-string-replace": "^0.4.4",
|
||||||
"react-syntax-highlighter": "^15.4.3",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"react-textarea-autosize": "^8.3.3",
|
"react-textarea-autosize": "^8.3.4",
|
||||||
"react-twitter-embed": "^4.0.4",
|
"react-twitter-embed": "^4.0.4",
|
||||||
"react-youtube": "^7.14.0",
|
"react-youtube": "^7.14.0",
|
||||||
"recharts": "^2.1.10",
|
"recharts": "^2.1.16",
|
||||||
"remark-directive": "^2.0.1",
|
"remark-directive": "^2.0.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"remove-markdown": "^0.3.0",
|
"remove-markdown": "^0.3.0",
|
||||||
"sass": "^1.32.8",
|
"sass": "^1.56.0",
|
||||||
"secp256k1": "^4.0.2",
|
"secp256k1": "^4.0.3",
|
||||||
"swr": "^0.5.4",
|
"swr": "^1.3.0",
|
||||||
"unist-util-visit": "^4.1.0",
|
"unist-util-visit": "^4.1.1",
|
||||||
"use-dark-mode": "^2.3.1",
|
"use-dark-mode": "^2.3.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webln": "^0.2.2",
|
"webln": "^0.2.2",
|
||||||
"yup": "^0.32.9"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "14.17.0"
|
"node": "14.17.0"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
"parser": "babel-eslint",
|
"parser": "@babel/eslint-parser",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"eslint-plugin-compat"
|
"eslint-plugin-compat"
|
||||||
],
|
],
|
||||||
@ -82,9 +89,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-eslint": "^10.1.0",
|
"@babel/core": "^7.20.2",
|
||||||
"eslint": "^7.29.0",
|
"@babel/eslint-parser": "^7.19.1",
|
||||||
"eslint-plugin-compat": "^3.9.0",
|
"eslint": "^7.32.0",
|
||||||
"standard": "^16.0.3"
|
"eslint-plugin-compat": "^4.0.2",
|
||||||
|
"standard": "^16.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import '../styles/globals.scss'
|
import '../styles/globals.scss'
|
||||||
import { ApolloProvider, gql, useQuery } from '@apollo/client'
|
import { ApolloProvider, gql, useQuery } from '@apollo/client'
|
||||||
import { Provider } from 'next-auth/client'
|
import { Provider } from 'next-auth/client'
|
||||||
import { FundErrorModal, FundErrorProvider } from '../components/fund-error'
|
|
||||||
import { MeProvider } from '../components/me'
|
import { MeProvider } from '../components/me'
|
||||||
import PlausibleProvider from 'next-plausible'
|
import PlausibleProvider from 'next-plausible'
|
||||||
import { LightningProvider } from '../components/lightning'
|
import { LightningProvider } from '../components/lightning'
|
||||||
import { ItemActModal, ItemActProvider } from '../components/item-act'
|
|
||||||
import getApolloClient from '../lib/apollo'
|
import getApolloClient from '../lib/apollo'
|
||||||
import NextNProgress from 'nextjs-progressbar'
|
import NextNProgress from 'nextjs-progressbar'
|
||||||
import { PriceProvider } from '../components/price'
|
import { PriceProvider } from '../components/price'
|
||||||
@ -14,6 +12,7 @@ import { useRouter } from 'next/dist/client/router'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import Moon from '../svgs/moon-fill.svg'
|
import Moon from '../svgs/moon-fill.svg'
|
||||||
import Layout from '../components/layout'
|
import Layout from '../components/layout'
|
||||||
|
import { ShowModalProvider } from '../components/modal'
|
||||||
|
|
||||||
function CSRWrapper ({ Component, apollo, ...props }) {
|
function CSRWrapper ({ Component, apollo, ...props }) {
|
||||||
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
const { data, error } = useQuery(gql`${apollo.query}`, { variables: apollo.variables, fetchPolicy: 'cache-first' })
|
||||||
@ -42,17 +41,19 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||||||
const client = getApolloClient()
|
const client = getApolloClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(async () => {
|
useEffect(() => {
|
||||||
// HACK: 'cause there's no way to tell Next to skip SSR
|
// HACK: 'cause there's no way to tell Next to skip SSR
|
||||||
// So every page load, we modify the route in browser history
|
// So every page load, we modify the route in browser history
|
||||||
// to point to the same page but without SSR, ie ?nodata=true
|
// to point to the same page but without SSR, ie ?nodata=true
|
||||||
// this nodata var will get passed to the server on back/foward and
|
// this nodata var will get passed to the server on back/foward and
|
||||||
// 1. prevent data from reloading and 2. perserve scroll
|
// 1. prevent data from reloading and 2. perserve scroll
|
||||||
// (2) is not possible while intercepting nav with beforePopState
|
// (2) is not possible while intercepting nav with beforePopState
|
||||||
router.replace({
|
if (router.isReady) {
|
||||||
pathname: router.pathname,
|
router.replace({
|
||||||
query: { ...router.query, nodata: true }
|
pathname: router.pathname,
|
||||||
}, router.asPath, { ...router.options, scroll: false })
|
query: { ...router.query, nodata: true }
|
||||||
|
}, router.asPath, { ...router.options, scroll: false })
|
||||||
|
}
|
||||||
}, [router.asPath])
|
}, [router.asPath])
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -60,7 +61,7 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||||||
ssr data
|
ssr data
|
||||||
*/
|
*/
|
||||||
const { apollo, data, me, price } = props
|
const { apollo, data, me, price } = props
|
||||||
if (typeof window !== 'undefined' && apollo && data) {
|
if (apollo && data) {
|
||||||
client.writeQuery({
|
client.writeQuery({
|
||||||
query: gql`${apollo.query}`,
|
query: gql`${apollo.query}`,
|
||||||
data: data,
|
data: data,
|
||||||
@ -87,15 +88,11 @@ function MyApp ({ Component, pageProps: { session, ...props } }) {
|
|||||||
<MeProvider me={me}>
|
<MeProvider me={me}>
|
||||||
<PriceProvider price={price}>
|
<PriceProvider price={price}>
|
||||||
<LightningProvider>
|
<LightningProvider>
|
||||||
<FundErrorProvider>
|
<ShowModalProvider>
|
||||||
<FundErrorModal />
|
{data || !apollo?.query
|
||||||
<ItemActProvider>
|
? <Component {...props} />
|
||||||
<ItemActModal />
|
: <CSRWrapper Component={Component} {...props} />}
|
||||||
{data || !apollo?.query
|
</ShowModalProvider>
|
||||||
? <Component {...props} />
|
|
||||||
: <CSRWrapper Component={Component} {...props} />}
|
|
||||||
</ItemActProvider>
|
|
||||||
</FundErrorProvider>
|
|
||||||
</LightningProvider>
|
</LightningProvider>
|
||||||
</PriceProvider>
|
</PriceProvider>
|
||||||
</MeProvider>
|
</MeProvider>
|
||||||
|
@ -5,9 +5,7 @@ import prisma from '../../../api/models'
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import { getSession } from 'next-auth/client'
|
import { getSession } from 'next-auth/client'
|
||||||
|
|
||||||
export default (req, res) => NextAuth(req, res, options)
|
export default (req, res) => NextAuth(req, res, {
|
||||||
|
|
||||||
const options = {
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
/**
|
/**
|
||||||
* @param {object} token Decrypted JSON Web Token
|
* @param {object} token Decrypted JSON Web Token
|
||||||
@ -26,22 +24,32 @@ const options = {
|
|||||||
token.user = { id: Number(user.id) }
|
token.user = { id: Number(user.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// sign them up for the newsletter
|
if (isNewUser) {
|
||||||
if (isNewUser && profile.email) {
|
// if referrer exists, set on user
|
||||||
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
if (req.cookies.sn_referrer && user?.id) {
|
||||||
method: 'POST',
|
const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
|
||||||
headers: {
|
if (referrer) {
|
||||||
'Content-Type': 'application/json',
|
await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
|
||||||
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
}
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
|
||||||
email: profile.email,
|
// sign them up for the newsletter
|
||||||
name: 'blank',
|
if (profile.email) {
|
||||||
lists: [2],
|
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
||||||
status: 'enabled',
|
method: 'POST',
|
||||||
preconfirm_subscriptions: true
|
headers: {
|
||||||
})
|
'Content-Type': 'application/json',
|
||||||
}).then(async r => console.log(await r.json())).catch(console.log)
|
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: profile.email,
|
||||||
|
name: 'blank',
|
||||||
|
lists: [2],
|
||||||
|
status: 'enabled',
|
||||||
|
preconfirm_subscriptions: true
|
||||||
|
})
|
||||||
|
}).then(async r => console.log(await r.json())).catch(console.log)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return token
|
return token
|
||||||
@ -128,9 +136,10 @@ const options = {
|
|||||||
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY
|
signingKey: process.env.JWT_SIGNING_PRIVATE_KEY
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/login'
|
signIn: '/login',
|
||||||
|
verifyRequest: '/email'
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
function sendVerificationRequest ({
|
function sendVerificationRequest ({
|
||||||
identifier: email,
|
identifier: email,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import AWS from 'aws-sdk'
|
import AWS from 'aws-sdk'
|
||||||
import {PassThrough} from 'stream'
|
import { PassThrough } from 'stream'
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
const encodeS3URI = require('node-s3-url-encode')
|
const encodeS3URI = require('node-s3-url-encode')
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ const bucketRegion = 'us-east-1'
|
|||||||
const contentType = 'image/png'
|
const contentType = 'image/png'
|
||||||
const bucketUrl = 'https://sn-capture.s3.amazonaws.com/'
|
const bucketUrl = 'https://sn-capture.s3.amazonaws.com/'
|
||||||
const s3PathPrefix = process.env.NODE_ENV === 'development' ? 'dev/' : ''
|
const s3PathPrefix = process.env.NODE_ENV === 'development' ? 'dev/' : ''
|
||||||
var capturing = false
|
let capturing = false
|
||||||
|
|
||||||
AWS.config.update({
|
AWS.config.update({
|
||||||
region: bucketRegion
|
region: bucketRegion
|
||||||
@ -22,13 +22,13 @@ export default async function handler (req, res) {
|
|||||||
const s3PathPUT = s3PathPrefix + (joinedPath === '.' ? '_' : joinedPath) + searchQ
|
const s3PathPUT = s3PathPrefix + (joinedPath === '.' ? '_' : joinedPath) + searchQ
|
||||||
const s3PathGET = s3PathPrefix + (joinedPath === '.' ? '_' : joinedPath) + encodeS3URI(searchQ)
|
const s3PathGET = s3PathPrefix + (joinedPath === '.' ? '_' : joinedPath) + encodeS3URI(searchQ)
|
||||||
const url = process.env.PUBLIC_URL + '/' + joinedPath + searchQ
|
const url = process.env.PUBLIC_URL + '/' + joinedPath + searchQ
|
||||||
const aws = new AWS.S3({apiVersion: '2006-03-01'})
|
const aws = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||||
|
|
||||||
// check to see if we have a recent version of the object
|
// check to see if we have a recent version of the object
|
||||||
aws.headObject({
|
aws.headObject({
|
||||||
Bucket: bucketName,
|
Bucket: bucketName,
|
||||||
Key: s3PathPUT,
|
Key: s3PathPUT,
|
||||||
IfModifiedSince : new Date(new Date().getTime() - 15*60000)
|
IfModifiedSince: new Date(new Date().getTime() - 15 * 60000)
|
||||||
}).promise().then(() => {
|
}).promise().then(() => {
|
||||||
// this path is cached so return it
|
// this path is cached so return it
|
||||||
res.writeHead(302, { Location: bucketUrl + s3PathGET }).end()
|
res.writeHead(302, { Location: bucketUrl + s3PathGET }).end()
|
||||||
@ -37,7 +37,7 @@ export default async function handler (req, res) {
|
|||||||
// we don't have it cached, so capture it and cache it
|
// we don't have it cached, so capture it and cache it
|
||||||
if (capturing) {
|
if (capturing) {
|
||||||
return res.writeHead(503, {
|
return res.writeHead(503, {
|
||||||
'Retry-After' : 1
|
'Retry-After': 1
|
||||||
}).end()
|
}).end()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ export default async function handler (req, res) {
|
|||||||
|
|
||||||
res.setHeader('Content-Type', contentType)
|
res.setHeader('Content-Type', contentType)
|
||||||
const capture = spawn(
|
const capture = spawn(
|
||||||
'node', ['./spawn/capture.js', url], {maxBuffer: 1024*1024*5})
|
'node', ['./spawn/capture.js', url], { maxBuffer: 1024 * 1024 * 5 })
|
||||||
|
|
||||||
capture.on('close', code => {
|
capture.on('close', code => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
@ -74,4 +74,4 @@ export default async function handler (req, res) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -51,4 +51,11 @@ export const config = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apolloServer.createHandler({ path: '/api/graphql' })
|
const startServer = apolloServer.start()
|
||||||
|
|
||||||
|
export default async function handler (req, res) {
|
||||||
|
await startServer
|
||||||
|
await apolloServer.createHandler({
|
||||||
|
path: '/api/graphql'
|
||||||
|
})(req, res)
|
||||||
|
}
|
||||||
|
@ -32,7 +32,7 @@ export default async ({ query }, res) => {
|
|||||||
k1: query.k1, // Random or non-random string to identify the user's LN WALLET when using the callback URL
|
k1: query.k1, // Random or non-random string to identify the user's LN WALLET when using the callback URL
|
||||||
defaultDescription: `Withdrawal for @${user.name} on SN`, // A default withdrawal invoice description
|
defaultDescription: `Withdrawal for @${user.name} on SN`, // A default withdrawal invoice description
|
||||||
minWithdrawable: 1000, // Min amount (in millisatoshis) the user can withdraw from LN SERVICE, or 0
|
minWithdrawable: 1000, // Min amount (in millisatoshis) the user can withdraw from LN SERVICE, or 0
|
||||||
maxWithdrawable: user.msats - 10000 // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts
|
maxWithdrawable: Number(user.msats - 10000n) // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
reason = 'user not found'
|
reason = 'user not found'
|
||||||
|
28
pages/api/nostr/nip05.js
Normal file
28
pages/api/nostr/nip05.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import models from '../../../api/models'
|
||||||
|
|
||||||
|
export default async function Nip05 ({ query: { name } }, res) {
|
||||||
|
const names = {}
|
||||||
|
let relays = {}
|
||||||
|
|
||||||
|
const users = await models.user.findMany({
|
||||||
|
where: {
|
||||||
|
name,
|
||||||
|
nostrPubkey: { not: null }
|
||||||
|
},
|
||||||
|
include: { nostrRelays: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
names[user.name] = user.nostrPubkey
|
||||||
|
if (user.nostrRelays.length) {
|
||||||
|
// append relays with key pubkey
|
||||||
|
relays[user.nostrPubkey] = []
|
||||||
|
for (const relay of user.nostrRelays) {
|
||||||
|
relays[user.nostrPubkey].push(relay.nostrRelayAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relays = Object.keys(relays).length ? relays : undefined
|
||||||
|
return res.status(200).json({ names, relays })
|
||||||
|
}
|
14
pages/email.js
Normal file
14
pages/email.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import LayoutError from '../components/layout-error'
|
||||||
|
import { Image } from 'react-bootstrap'
|
||||||
|
|
||||||
|
export default function Email () {
|
||||||
|
return (
|
||||||
|
<LayoutError>
|
||||||
|
<div className='p-4 text-center'>
|
||||||
|
<h1>Check your email</h1>
|
||||||
|
<h4 className='pb-4'>A sign in link has been sent to your email address</h4>
|
||||||
|
<Image width='500' height='376' src='/hello.gif' fluid />
|
||||||
|
</div>
|
||||||
|
</LayoutError>
|
||||||
|
)
|
||||||
|
}
|
@ -6,6 +6,7 @@ import { gql } from '@apollo/client'
|
|||||||
import { INVITE_FIELDS } from '../../fragments/invites'
|
import { INVITE_FIELDS } from '../../fragments/invites'
|
||||||
import getSSRApolloClient from '../../api/ssrApollo'
|
import getSSRApolloClient from '../../api/ssrApollo'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import LayoutCenter from '../../components/layout-center'
|
||||||
|
|
||||||
export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
|
export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
|
||||||
const session = await getSession({ req })
|
const session = await getSession({ req })
|
||||||
@ -63,7 +64,7 @@ function InviteHeader ({ invite }) {
|
|||||||
} else {
|
} else {
|
||||||
Inner = () => (
|
Inner = () => (
|
||||||
<div>
|
<div>
|
||||||
get <span className='text-success'>{invite.gift} free sats</span> from{' '}
|
Get <span className='text-success'>{invite.gift} free sats</span> from{' '}
|
||||||
<Link href={`/${invite.user.name}`} passHref><a>@{invite.user.name}</a></Link>{' '}
|
<Link href={`/${invite.user.name}`} passHref><a>@{invite.user.name}</a></Link>{' '}
|
||||||
when you sign up today
|
when you sign up today
|
||||||
</div>
|
</div>
|
||||||
@ -71,12 +72,16 @@ function InviteHeader ({ invite }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<h2 className='text-center pb-3'>
|
<h3 className='text-center pb-3'>
|
||||||
<Inner />
|
<Inner />
|
||||||
</h2>
|
</h3>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Invite ({ invite, ...props }) {
|
export default function Invite ({ invite, ...props }) {
|
||||||
return <Login Header={() => <InviteHeader invite={invite} />} {...props} />
|
return (
|
||||||
|
<LayoutCenter>
|
||||||
|
<Login Header={() => <InviteHeader invite={invite} />} text='Sign up' {...props} />
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -120,7 +120,7 @@ export default function Invites () {
|
|||||||
<h2 className='mt-3 mb-0'>
|
<h2 className='mt-3 mb-0'>
|
||||||
invite links
|
invite links
|
||||||
</h2>
|
</h2>
|
||||||
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust somewhat, e.g. group chats or DMs</small>
|
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust, e.g. group chats or DMs</small>
|
||||||
</div>
|
</div>
|
||||||
<InviteForm />
|
<InviteForm />
|
||||||
{active.length > 0 && <InviteList name='active' invites={active} />}
|
{active.length > 0 && <InviteList name='active' invites={active} />}
|
||||||
|
@ -19,7 +19,10 @@ function LoadInvoice () {
|
|||||||
pollInterval: 1000,
|
pollInterval: 1000,
|
||||||
variables: { id: router.query.id }
|
variables: { id: router.query.id }
|
||||||
})
|
})
|
||||||
if (error) return <div>error</div>
|
if (error) {
|
||||||
|
console.log(error)
|
||||||
|
return <div>error</div>
|
||||||
|
}
|
||||||
if (!data || loading) {
|
if (!data || loading) {
|
||||||
return <LnQRSkeleton status='loading' />
|
return <LnQRSkeleton status='loading' />
|
||||||
}
|
}
|
||||||
|
146
pages/lexical.js
Normal file
146
pages/lexical.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import LayoutCenter from '../components/layout-center'
|
||||||
|
import styles from '../lexical/styles.module.css'
|
||||||
|
|
||||||
|
import Theme from '../lexical/theme'
|
||||||
|
import ListMaxIndentLevelPlugin from '../lexical/plugins/list-max-indent'
|
||||||
|
import AutoLinkPlugin from '../lexical/plugins/autolink'
|
||||||
|
import ToolbarPlugin from '../lexical/plugins/toolbar'
|
||||||
|
import LinkTooltipPlugin from '../lexical/plugins/link-tooltip'
|
||||||
|
|
||||||
|
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||||
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||||
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||||
|
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
|
||||||
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||||
|
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'
|
||||||
|
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
|
||||||
|
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||||
|
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
|
||||||
|
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
|
||||||
|
import { ListItemNode, ListNode } from '@lexical/list'
|
||||||
|
import { CodeHighlightNode, CodeNode } from '@lexical/code'
|
||||||
|
import { AutoLinkNode, LinkNode } from '@lexical/link'
|
||||||
|
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
||||||
|
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
||||||
|
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import LinkInsertPlugin, { LinkInsertProvider } from '../lexical/plugins/link-insert'
|
||||||
|
import { ImageNode } from '../lexical/nodes/image'
|
||||||
|
import ImageInsertPlugin from '../lexical/plugins/image-insert'
|
||||||
|
import { SN_TRANSFORMERS } from '../lexical/utils/image-markdown-transformer'
|
||||||
|
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
|
import Text from '../components/text'
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
|
||||||
|
const editorConfig = {
|
||||||
|
// The editor theme
|
||||||
|
theme: Theme,
|
||||||
|
// Handling of errors during update
|
||||||
|
onError (error) {
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
// Any custom nodes go here
|
||||||
|
nodes: [
|
||||||
|
HeadingNode,
|
||||||
|
ListNode,
|
||||||
|
ListItemNode,
|
||||||
|
QuoteNode,
|
||||||
|
CodeNode,
|
||||||
|
CodeHighlightNode,
|
||||||
|
TableNode,
|
||||||
|
TableCellNode,
|
||||||
|
TableRowNode,
|
||||||
|
AutoLinkNode,
|
||||||
|
LinkNode,
|
||||||
|
HorizontalRuleNode,
|
||||||
|
ImageNode
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor ({ markdown }) {
|
||||||
|
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
||||||
|
|
||||||
|
const onRef = (_floatingAnchorElem) => {
|
||||||
|
if (_floatingAnchorElem !== null) {
|
||||||
|
setFloatingAnchorElem(_floatingAnchorElem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialConfig = editorConfig
|
||||||
|
if (markdown) {
|
||||||
|
initialConfig = { ...initialConfig, editorState: () => $convertFromMarkdownString(markdown, SN_TRANSFORMERS) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LexicalComposer initialConfig={initialConfig}>
|
||||||
|
<div className={styles.editorContainer}>
|
||||||
|
<div className={styles.editorInner}>
|
||||||
|
<LinkInsertProvider>
|
||||||
|
<ToolbarPlugin />
|
||||||
|
<LinkTooltipPlugin anchorElem={floatingAnchorElem} />
|
||||||
|
<LinkInsertPlugin />
|
||||||
|
</LinkInsertProvider>
|
||||||
|
<RichTextPlugin
|
||||||
|
contentEditable={
|
||||||
|
<div className={styles.editor} ref={onRef}>
|
||||||
|
<ContentEditable className={styles.editorInput} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placeholder={null}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<ImageInsertPlugin />
|
||||||
|
<AutoFocusPlugin />
|
||||||
|
<ListPlugin />
|
||||||
|
<LinkPlugin />
|
||||||
|
<AutoLinkPlugin />
|
||||||
|
<HistoryPlugin />
|
||||||
|
<ListMaxIndentLevelPlugin maxDepth={4} />
|
||||||
|
<MarkdownShortcutPlugin transformers={SN_TRANSFORMERS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!markdown && <Markdown />}
|
||||||
|
</LexicalComposer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Markdown () {
|
||||||
|
const [editor] = useLexicalComposerContext()
|
||||||
|
const [markdown, setMarkdown] = useState(null)
|
||||||
|
const [preview, togglePreview] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='lexical text-left w-100'>
|
||||||
|
<OnChangePlugin onChange={() => editor.update(() => {
|
||||||
|
setMarkdown($convertToMarkdownString(SN_TRANSFORMERS))
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Button size='sm' className='mb-2' onClick={() => togglePreview(!preview)}>{preview ? 'show markdown' : 'show preview'}</Button>
|
||||||
|
<div style={{ border: '1px solid var(--theme-color)', padding: '.5rem', borderRadius: '.4rem' }}>
|
||||||
|
|
||||||
|
{preview
|
||||||
|
? (
|
||||||
|
<Text>
|
||||||
|
{markdown}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<pre className='text-reset p-0 m-0'>
|
||||||
|
{markdown}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Lexical () {
|
||||||
|
return (
|
||||||
|
<LayoutCenter footerLinks>
|
||||||
|
<Editor />
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
import { providers, getSession } from 'next-auth/client'
|
import { providers, getSession } from 'next-auth/client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import LayoutCenter from '../components/layout-center'
|
||||||
import Login from '../components/login'
|
import Login from '../components/login'
|
||||||
|
|
||||||
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||||
@ -21,4 +23,19 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Login
|
function LoginFooter ({ callbackUrl }) {
|
||||||
|
return (
|
||||||
|
<small className='font-weight-bold text-muted pt-4'>Don't have an account? <Link href={{ pathname: '/signup', query: { callbackUrl } }}>sign up</Link></small>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage (props) {
|
||||||
|
return (
|
||||||
|
<LayoutCenter>
|
||||||
|
<Login
|
||||||
|
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { Nav, Navbar } from 'react-bootstrap'
|
|
||||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
import Layout from '../components/layout'
|
import Layout from '../components/layout'
|
||||||
import Notifications from '../components/notifications'
|
import Notifications from '../components/notifications'
|
||||||
import { NOTIFICATIONS } from '../fragments/notifications'
|
import { NOTIFICATIONS } from '../fragments/notifications'
|
||||||
import styles from '../components/header.module.css'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS)
|
export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS)
|
||||||
@ -14,7 +11,6 @@ export default function NotificationPage ({ data: { notifications: { notificatio
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<NotificationHeader />
|
|
||||||
<Notifications
|
<Notifications
|
||||||
notifications={notifications} cursor={cursor}
|
notifications={notifications} cursor={cursor}
|
||||||
lastChecked={lastChecked} variables={{ inc: router.query?.inc }}
|
lastChecked={lastChecked} variables={{ inc: router.query?.inc }}
|
||||||
@ -22,34 +18,3 @@ export default function NotificationPage ({ data: { notifications: { notificatio
|
|||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationHeader () {
|
|
||||||
const router = useRouter()
|
|
||||||
return (
|
|
||||||
<Navbar className='py-0'>
|
|
||||||
<Nav
|
|
||||||
className={`${styles.navbarNav} justify-content-around`}
|
|
||||||
activeKey={router.asPath}
|
|
||||||
>
|
|
||||||
<Nav.Item>
|
|
||||||
<Link href='/notifications' passHref>
|
|
||||||
<Nav.Link
|
|
||||||
className={styles.navLink}
|
|
||||||
>
|
|
||||||
all
|
|
||||||
</Nav.Link>
|
|
||||||
</Link>
|
|
||||||
</Nav.Item>
|
|
||||||
<Nav.Item>
|
|
||||||
<Link href='/notifications?inc=replies' passHref>
|
|
||||||
<Nav.Link
|
|
||||||
className={styles.navLink}
|
|
||||||
>
|
|
||||||
replies
|
|
||||||
</Nav.Link>
|
|
||||||
</Link>
|
|
||||||
</Nav.Item>
|
|
||||||
</Nav>
|
|
||||||
</Navbar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
22
pages/recent/[type].js
Normal file
22
pages/recent/[type].js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Layout from '../../components/layout'
|
||||||
|
import Items from '../../components/items'
|
||||||
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
|
import { ITEMS } from '../../fragments/items'
|
||||||
|
import RecentHeader from '../../components/recent-header'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
const variables = { sort: 'recent' }
|
||||||
|
export const getServerSideProps = getGetServerSideProps(ITEMS, variables)
|
||||||
|
|
||||||
|
export default function Index ({ data: { items: { items, cursor } } }) {
|
||||||
|
const router = useRouter()
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<RecentHeader />
|
||||||
|
<Items
|
||||||
|
items={items} cursor={cursor}
|
||||||
|
variables={{ ...variables, type: router.query.type }} rank
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
@ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(MORE_FLAT_COMMENTS, vari
|
|||||||
export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) {
|
export default function Index ({ data: { moreFlatComments: { comments, cursor } } }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<RecentHeader itemType='comments' />
|
<RecentHeader type='comments' />
|
||||||
<CommentsFlat
|
<CommentsFlat
|
||||||
comments={comments} cursor={cursor}
|
comments={comments} cursor={cursor}
|
||||||
variables={{ sort: 'recent' }} includeParent noReply
|
variables={{ sort: 'recent' }} includeParent noReply
|
||||||
|
@ -10,7 +10,7 @@ export const getServerSideProps = getGetServerSideProps(ITEMS, variables)
|
|||||||
export default function Index ({ data: { items: { items, cursor } } }) {
|
export default function Index ({ data: { items: { items, cursor } } }) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<RecentHeader itemType='posts' />
|
<RecentHeader type='posts' />
|
||||||
<Items
|
<Items
|
||||||
items={items} cursor={cursor}
|
items={items} cursor={cursor}
|
||||||
variables={variables} rank
|
variables={variables} rank
|
||||||
|
75
pages/referrals/[when].js
Normal file
75
pages/referrals/[when].js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { gql } from 'apollo-server-micro'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
|
import { CopyInput, Form, Select } from '../../components/form'
|
||||||
|
import LayoutCenter from '../../components/layout-center'
|
||||||
|
import { useMe } from '../../components/me'
|
||||||
|
import { WhenComposedChart } from '../../components/when-charts'
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps(
|
||||||
|
gql`
|
||||||
|
query Referrals($when: String!)
|
||||||
|
{
|
||||||
|
referrals(when: $when) {
|
||||||
|
totalSats
|
||||||
|
totalReferrals
|
||||||
|
stats {
|
||||||
|
time
|
||||||
|
data {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
export default function Referrals ({ data: { referrals: { totalSats, totalReferrals, stats } } }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const me = useMe()
|
||||||
|
return (
|
||||||
|
<LayoutCenter footerLinks>
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
when: router.query.when
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 className='font-weight-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'>
|
||||||
|
{totalReferrals} referrals & {totalSats} sats in the last
|
||||||
|
<Select
|
||||||
|
groupClassName='mb-0 ml-2'
|
||||||
|
className='w-auto'
|
||||||
|
name='when'
|
||||||
|
size='sm'
|
||||||
|
items={['day', 'week', 'month', 'year', 'forever']}
|
||||||
|
onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)}
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
</Form>
|
||||||
|
<WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='text-small pt-5 px-3 d-flex w-100 align-items-center'
|
||||||
|
>
|
||||||
|
<div className='nav-item text-muted pr-2' style={{ 'white-space': 'nowrap' }}>referral link:</div>
|
||||||
|
<CopyInput
|
||||||
|
size='sm'
|
||||||
|
groupClassName='mb-0 w-100'
|
||||||
|
readOnly
|
||||||
|
noForm
|
||||||
|
placeholder={`https://stacker.news/r/${me.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className='py-3 text-muted'>
|
||||||
|
<li>{`appending /r/${me.name} to any SN link makes it a ref link`}
|
||||||
|
<ul>
|
||||||
|
<li>e.g. https://stacker.news/items/1/r/{me.name}</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>earn 21% of boost and job fees spent by referred stackers</li>
|
||||||
|
<li>earn 2.1% of all tips received by referred stackers</li>
|
||||||
|
<li><Link href='/invites' passHref><a>invite links</a></Link> are also implicitly referral links</li>
|
||||||
|
</ul>
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
|
}
|
142
pages/rewards.js
Normal file
142
pages/rewards.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { gql } from 'apollo-server-micro'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Button, InputGroup, Modal } from 'react-bootstrap'
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
|
import { getGetServerSideProps } from '../api/ssrApollo'
|
||||||
|
import { Form, Input, SubmitButton } from '../components/form'
|
||||||
|
import LayoutCenter from '../components/layout-center'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const REWARDS = gql`
|
||||||
|
{
|
||||||
|
expectedRewards {
|
||||||
|
total
|
||||||
|
sources {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps(REWARDS)
|
||||||
|
|
||||||
|
export default function Rewards ({ data: { expectedRewards: { total, sources } } }) {
|
||||||
|
const { data } = useQuery(REWARDS, { pollInterval: 1000 })
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
({ expectedRewards: { total, sources } } = data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutCenter footerLinks>
|
||||||
|
<h4 className='font-weight-bold text-muted text-center'>
|
||||||
|
<div>{total} sats to be rewarded today</div>
|
||||||
|
<Link href='/faq#how-do-i-earn-sats-on-stacker-news' passHref>
|
||||||
|
<a className='text-reset'><small><small><small>learn about rewards</small></small></small></a>
|
||||||
|
</Link>
|
||||||
|
</h4>
|
||||||
|
<div className='my-3 w-100'>
|
||||||
|
<GrowthPieChart data={sources} />
|
||||||
|
</div>
|
||||||
|
<DonateButton />
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'var(--secondary)',
|
||||||
|
'var(--info)',
|
||||||
|
'var(--success)',
|
||||||
|
'var(--boost)',
|
||||||
|
'var(--grey)'
|
||||||
|
]
|
||||||
|
|
||||||
|
function GrowthPieChart ({ data }) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width='100%' height={250} minWidth={200}>
|
||||||
|
<PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
|
||||||
|
<Pie
|
||||||
|
dataKey='value'
|
||||||
|
isAnimationActive={false}
|
||||||
|
data={data}
|
||||||
|
cx='50%'
|
||||||
|
cy='50%'
|
||||||
|
outerRadius={80}
|
||||||
|
fill='var(--secondary)'
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{
|
||||||
|
data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DonateSchema = Yup.object({
|
||||||
|
amount: Yup.number().typeError('must be a number').required('required')
|
||||||
|
.positive('must be positive').integer('must be whole')
|
||||||
|
})
|
||||||
|
|
||||||
|
export function DonateButton () {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const [donateToRewards] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation donateToRewards($sats: Int!) {
|
||||||
|
donateToRewards(sats: $sats)
|
||||||
|
}`)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [show])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setShow(true)}>DONATE TO REWARDS</Button>
|
||||||
|
<Modal
|
||||||
|
show={show}
|
||||||
|
onHide={() => {
|
||||||
|
setShow(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='modal-close' onClick={() => setShow(false)}>X</div>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form
|
||||||
|
initial={{
|
||||||
|
amount: 1000
|
||||||
|
}}
|
||||||
|
schema={DonateSchema}
|
||||||
|
onSubmit={async ({ amount }) => {
|
||||||
|
await donateToRewards({
|
||||||
|
variables: {
|
||||||
|
sats: Number(amount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setShow(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label='amount'
|
||||||
|
name='amount'
|
||||||
|
innerRef={inputRef}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
/>
|
||||||
|
<div className='d-flex'>
|
||||||
|
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>donate</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -97,6 +97,25 @@ function Detail ({ fact }) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (fact.type === 'donation') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={satusClass(fact.status)}>
|
||||||
|
You made a donation to <Link href='/rewards' passHref><a>daily rewards</a></Link>!
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (fact.type === 'referral') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={satusClass(fact.status)}>
|
||||||
|
You stacked sats from <Link href='/referrals/month' passHref><a>a referral</a></Link>!
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!fact.item) {
|
if (!fact.item) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -144,6 +163,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||||||
case 'invoice':
|
case 'invoice':
|
||||||
return `/${fact.type}s/${fact.factId}`
|
return `/${fact.type}s/${fact.factId}`
|
||||||
case 'earn':
|
case 'earn':
|
||||||
|
case 'donation':
|
||||||
|
case 'referral':
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
return `/items/${fact.factId}`
|
return `/items/${fact.factId}`
|
||||||
@ -199,7 +220,9 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||||||
<tr>
|
<tr>
|
||||||
<th className={styles.type}>type</th>
|
<th className={styles.type}>type</th>
|
||||||
<th>detail</th>
|
<th>detail</th>
|
||||||
<th className={styles.sats}>sats</th>
|
<th className={styles.sats}>
|
||||||
|
sats
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -213,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||||||
<td className={styles.description}>
|
<td className={styles.description}>
|
||||||
<Detail fact={f} />
|
<Detail fact={f} />
|
||||||
</td>
|
</td>
|
||||||
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.msats / 1000)}</td>
|
<td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Checkbox, Form, Input, SubmitButton, Select } from '../components/form'
|
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../components/form'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import { Alert, Button, InputGroup, Modal } from 'react-bootstrap'
|
import { Alert, Button, InputGroup, Modal } from 'react-bootstrap'
|
||||||
import LayoutCenter from '../components/layout-center'
|
import LayoutCenter from '../components/layout-center'
|
||||||
@ -13,6 +13,10 @@ import { SETTINGS, SET_SETTINGS } from '../fragments/users'
|
|||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Info from '../components/info'
|
import Info from '../components/info'
|
||||||
import { CURRENCY_SYMBOLS } from '../components/price'
|
import { CURRENCY_SYMBOLS } from '../components/price'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import AccordianItem from '../components/accordian-item'
|
||||||
|
import { MAX_NOSTR_RELAY_NUM } from '../lib/constants'
|
||||||
|
import { WS_REGEXP } from '../lib/url'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps(SETTINGS)
|
export const getServerSideProps = getGetServerSideProps(SETTINGS)
|
||||||
|
|
||||||
@ -21,7 +25,12 @@ const supportedCurrencies = Object.keys(CURRENCY_SYMBOLS)
|
|||||||
export const SettingsSchema = Yup.object({
|
export const SettingsSchema = Yup.object({
|
||||||
tipDefault: Yup.number().typeError('must be a number').required('required')
|
tipDefault: Yup.number().typeError('must be a number').required('required')
|
||||||
.positive('must be positive').integer('must be whole'),
|
.positive('must be positive').integer('must be whole'),
|
||||||
fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies)
|
fiatCurrency: Yup.string().required('required').oneOf(supportedCurrencies),
|
||||||
|
nostrPubkey: Yup.string().matches(/^[0-9a-fA-F]{64}$/, 'must be 64 hex chars'),
|
||||||
|
nostrRelays: Yup.array().of(
|
||||||
|
Yup.string().matches(WS_REGEXP, 'invalid web socket address')
|
||||||
|
).max(MAX_NOSTR_RELAY_NUM,
|
||||||
|
({ max, value }) => `${Math.abs(max - value.length)} too many`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
||||||
@ -58,6 +67,7 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
tipDefault: settings?.tipDefault || 21,
|
tipDefault: settings?.tipDefault || 21,
|
||||||
|
turboTipping: settings?.turboTipping,
|
||||||
fiatCurrency: settings?.fiatCurrency || 'USD',
|
fiatCurrency: settings?.fiatCurrency || 'USD',
|
||||||
noteItemSats: settings?.noteItemSats,
|
noteItemSats: settings?.noteItemSats,
|
||||||
noteEarning: settings?.noteEarning,
|
noteEarning: settings?.noteEarning,
|
||||||
@ -67,12 +77,28 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
noteInvites: settings?.noteInvites,
|
noteInvites: settings?.noteInvites,
|
||||||
noteJobIndicator: settings?.noteJobIndicator,
|
noteJobIndicator: settings?.noteJobIndicator,
|
||||||
hideInvoiceDesc: settings?.hideInvoiceDesc,
|
hideInvoiceDesc: settings?.hideInvoiceDesc,
|
||||||
|
hideFromTopUsers: settings?.hideFromTopUsers,
|
||||||
wildWestMode: settings?.wildWestMode,
|
wildWestMode: settings?.wildWestMode,
|
||||||
greeterMode: settings?.greeterMode
|
greeterMode: settings?.greeterMode,
|
||||||
|
nostrPubkey: settings?.nostrPubkey || '',
|
||||||
|
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : ['']
|
||||||
}}
|
}}
|
||||||
schema={SettingsSchema}
|
schema={SettingsSchema}
|
||||||
onSubmit={async ({ tipDefault, ...values }) => {
|
onSubmit={async ({ tipDefault, nostrPubkey, nostrRelays, ...values }) => {
|
||||||
await setSettings({ variables: { tipDefault: Number(tipDefault), ...values } })
|
if (nostrPubkey.length === 0) {
|
||||||
|
nostrPubkey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
|
||||||
|
|
||||||
|
await setSettings({
|
||||||
|
variables: {
|
||||||
|
tipDefault: Number(tipDefault),
|
||||||
|
nostrPubkey,
|
||||||
|
nostrRelays: nostrRelaysFiltered,
|
||||||
|
...values
|
||||||
|
}
|
||||||
|
})
|
||||||
setSuccess('settings saved')
|
setSuccess('settings saved')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -80,10 +106,44 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
<Input
|
<Input
|
||||||
label='tip default'
|
label='tip default'
|
||||||
name='tipDefault'
|
name='tipDefault'
|
||||||
|
groupClassName='mb-0'
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to tip custom amounts</small>}
|
||||||
/>
|
/>
|
||||||
|
<div className='mb-2'>
|
||||||
|
<AccordianItem
|
||||||
|
show={settings?.turboTipping}
|
||||||
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
|
||||||
|
body={<Checkbox
|
||||||
|
name='turboTipping'
|
||||||
|
label={
|
||||||
|
<div className='d-flex align-items-center'>turbo tipping
|
||||||
|
<Info>
|
||||||
|
<ul className='font-weight-bold'>
|
||||||
|
<li>Makes every additional bolt click raise your total tip to another 10x multiple of your default tip</li>
|
||||||
|
<li>e.g. if your tip default is 10 sats
|
||||||
|
<ul>
|
||||||
|
<li>1st click: 10 sats total tipped</li>
|
||||||
|
<li>2nd click: 100 sats total tipped</li>
|
||||||
|
<li>3rd click: 1000 sats total tipped</li>
|
||||||
|
<li>4th click: 10000 sats total tipped</li>
|
||||||
|
<li>and so on ...</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>You can still custom tip via long press
|
||||||
|
<ul>
|
||||||
|
<li>the next bolt click rounds up to the next greatest 10x multiple of your default</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
label='fiat currency'
|
label='fiat currency'
|
||||||
name='fiatCurrency'
|
name='fiatCurrency'
|
||||||
@ -108,7 +168,7 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='my invite links are redeemed'
|
label='someone joins using my invite or referral links'
|
||||||
name='noteInvites'
|
name='noteInvites'
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
/>
|
/>
|
||||||
@ -144,6 +204,11 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
name='hideInvoiceDesc'
|
name='hideInvoiceDesc'
|
||||||
|
groupClassName='mb-0'
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<>hide me from <Link href='/top/users' passHref><a>top users</a></Link></>}
|
||||||
|
name='hideFromTopUsers'
|
||||||
/>
|
/>
|
||||||
<div className='form-label'>content</div>
|
<div className='form-label'>content</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -174,6 +239,27 @@ export default function Settings ({ data: { settings } }) {
|
|||||||
}
|
}
|
||||||
name='greeterMode'
|
name='greeterMode'
|
||||||
/>
|
/>
|
||||||
|
<AccordianItem
|
||||||
|
headerColor='var(--theme-color)'
|
||||||
|
show={settings?.nostrPubkey}
|
||||||
|
header={<h4 className='mb-2 text-left'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>}
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={<>pubkey <small className='text-muted ml-2'>optional</small></>}
|
||||||
|
name='nostrPubkey'
|
||||||
|
clear
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
label={<>relays <small className='text-muted ml-2'>optional</small></>}
|
||||||
|
name='nostrRelays'
|
||||||
|
clear
|
||||||
|
min={0}
|
||||||
|
max={MAX_NOSTR_RELAY_NUM}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className='d-flex'>
|
<div className='d-flex'>
|
||||||
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
|
<SubmitButton variant='info' className='ml-auto mt-1 px-4'>save</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
|
54
pages/signup.js
Normal file
54
pages/signup.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { providers, getSession } from 'next-auth/client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import LayoutCenter from '../components/layout-center'
|
||||||
|
import Login from '../components/login'
|
||||||
|
|
||||||
|
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||||
|
const session = await getSession({ req })
|
||||||
|
|
||||||
|
if (session && res && callbackUrl) {
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: callbackUrl
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return { props: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
providers: await providers({ req, res }),
|
||||||
|
callbackUrl,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignUpHeader () {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className='w-100 pb-2'>
|
||||||
|
Sign up
|
||||||
|
</h3>
|
||||||
|
<div className='font-weight-bold text-muted pb-4'>Join 9000+ bitcoiners and start stacking sats today</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignUpFooter ({ callbackUrl }) {
|
||||||
|
return (
|
||||||
|
<small className='font-weight-bold text-muted pt-4'>Already have an account? <Link href={{ pathname: '/login', query: { callbackUrl } }}>login</Link></small>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignUp ({ ...props }) {
|
||||||
|
return (
|
||||||
|
<LayoutCenter>
|
||||||
|
<Login
|
||||||
|
Header={() => <SignUpHeader />}
|
||||||
|
Footer={() => <SignUpFooter callbackUrl={props.callbackUrl} />}
|
||||||
|
text='Sign up'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</LayoutCenter>
|
||||||
|
)
|
||||||
|
}
|
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