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)} | ||||||
|  |         SELECT time, json_build_array( | ||||||
|  |           json_build_object('name', 'any', 'value', count(DISTINCT "userId")), | ||||||
|  |           json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')), | ||||||
|  |           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')), | ||||||
|  |           json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')), | ||||||
|  |           json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION')) | ||||||
|  |         ) AS data | ||||||
|  |         FROM times | ||||||
|  |         LEFT JOIN | ||||||
|  |         ((SELECT "ItemAct".created_at, "userId", act::text as act | ||||||
|           FROM "ItemAct" |           FROM "ItemAct" | ||||||
|         WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at) |           WHERE ${intervalClause(when, 'ItemAct', false)}) | ||||||
|         GROUP BY time |  | ||||||
|         ORDER BY time ASC`)
 |  | ||||||
|     }, |  | ||||||
|     itemGrowth: async (parent, args, { models }) => { |  | ||||||
|       return await models.$queryRaw( |  | ||||||
|         `SELECT date_trunc('month', created_at) AS time, count("parentId") as comments,
 |  | ||||||
|           count("subName") as jobs, count(*)-count("parentId")-count("subName") as posts |  | ||||||
|         FROM "Item" |  | ||||||
|         WHERE date_trunc('month', now_utc()) <> date_trunc('month', created_at) |  | ||||||
|         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" |  | ||||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" |  | ||||||
|           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)), | ||||||
|  |           json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)), | ||||||
|  |           json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0)) | ||||||
|  |         ) AS data | ||||||
|  |         FROM times | ||||||
|  |         LEFT JOIN | ||||||
|  |         ((SELECT "ItemAct".created_at, msats, act::text as act | ||||||
|  |           FROM "ItemAct" | ||||||
|  |           WHERE ${intervalClause(when, 'ItemAct', false)}) | ||||||
|  |         UNION ALL | ||||||
|  |         (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" |           FROM "ItemAct" | ||||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id |           JOIN "Item" on "ItemAct"."itemId" = "Item".id | ||||||
|         WHERE "ItemAct".created_at >= now_utc() - interval '1 week'`)
 |           WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP') | ||||||
| 
 |  | ||||||
|       return stats?.array |  | ||||||
|     }, |  | ||||||
|     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" |  | ||||||
|           JOIN "Item" on "ItemAct"."itemId" = "Item".id AND "Item"."userId" <> "ItemAct"."userId" |  | ||||||
|           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, "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 | ||||||
|  |           ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent | ||||||
|             FROM "ItemAct" |             FROM "ItemAct" | ||||||
|           JOIN users on "ItemAct"."userId" = users.id |  | ||||||
|             WHERE "ItemAct".created_at <= $1 |             WHERE "ItemAct".created_at <= $1 | ||||||
|             ${within('ItemAct', when)} |             ${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} |  | ||||||
|     </FundErrorContext.Provider> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function useFundError () { |  | ||||||
|   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> |       <p className='font-weight-bolder'>you need more sats</p> | ||||||
|       <div className='d-flex justify-content-end'> |       <div className='d-flex justify-content-end'> | ||||||
|         <Link href='/wallet?type=fund'> |         <Link href='/wallet?type=fund'> | ||||||
|             <Button variant='success' onClick={() => setError(false)}>fund</Button> |           <Button variant='success' onClick={onClose}>fund</Button> | ||||||
|         </Link> |         </Link> | ||||||
|       </div> |       </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') && | ||||||
|  |         <div> | ||||||
|           <Button |           <Button | ||||||
|           className='align-items-center d-flex pl-2 pr-3' |             className='align-items-center px-3 py-1 mr-2' | ||||||
|  |             id='signup' | ||||||
|  |             style={{ borderWidth: '2px' }} | ||||||
|  |             variant='outline-grey-darkmode' | ||||||
|  |             onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })} | ||||||
|  |           > | ||||||
|  |             login | ||||||
|  |           </Button> | ||||||
|  |           <Button | ||||||
|  |             className='align-items-center pl-2 py-1 pr-3' | ||||||
|  |             style={{ borderWidth: '2px' }} | ||||||
|             id='login' |             id='login' | ||||||
|           onClick={() => signIn(null, { callbackUrl: window.location.origin + router.asPath })} |             onClick={async () => await router.push({ pathname: '/signup', query: { callbackUrl: window.location.origin + router.asPath } })} | ||||||
|           > |           > | ||||||
|             <LightningIcon |             <LightningIcon | ||||||
|               width={17} |               width={17} | ||||||
|               height={17} |               height={17} | ||||||
|               className='mr-1' |               className='mr-1' | ||||||
|           />login |             />sign up | ||||||
|           </Button> |           </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; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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,57 +1,25 @@ | |||||||
| 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 |  | ||||||
|       show={!!item} |  | ||||||
|       onHide={() => { |  | ||||||
|         setItem(null) |  | ||||||
|       }} |  | ||||||
|     > |  | ||||||
|       <div className='modal-close' onClick={() => setItem(null)}>X</div> |  | ||||||
|       <Modal.Body> |  | ||||||
|     <Form |     <Form | ||||||
|       initial={{ |       initial={{ | ||||||
|         amount: me?.tipDefault, |         amount: me?.tipDefault, | ||||||
| @ -59,29 +27,43 @@ export function ItemActModal () { | |||||||
|       }} |       }} | ||||||
|       schema={ActSchema} |       schema={ActSchema} | ||||||
|       onSubmit={async ({ amount }) => { |       onSubmit={async ({ amount }) => { | ||||||
|             await item.act({ |         await act({ | ||||||
|           variables: { |           variables: { | ||||||
|                 id: item.itemId, |             id: itemId, | ||||||
|             sats: Number(amount) |             sats: Number(amount) | ||||||
|           } |           } | ||||||
|         }) |         }) | ||||||
|             await item.strike() |         await strike() | ||||||
|             setItem(null) |         onClose() | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <Input |       <Input | ||||||
|         label='amount' |         label='amount' | ||||||
|         name='amount' |         name='amount' | ||||||
|         innerRef={inputRef} |         innerRef={inputRef} | ||||||
|  |         overrideValue={oValue} | ||||||
|         required |         required | ||||||
|         autoFocus |         autoFocus | ||||||
|         append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} |         append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} | ||||||
|       /> |       /> | ||||||
|  |       <div> | ||||||
|  |         {[1, 10, 100, 1000, 10000].map(num => | ||||||
|  |           <Button | ||||||
|  |             size='sm' | ||||||
|  |             className={`${num > 1 ? 'ml-2' : ''} mb-2`} | ||||||
|  |             key={num} | ||||||
|  |             onClick={() => { setOValue(num) }} | ||||||
|  |           > | ||||||
|  |             <UpBolt | ||||||
|  |               className='mr-1' | ||||||
|  |               width={14} | ||||||
|  |               height={14} | ||||||
|  |             />{num} | ||||||
|  |           </Button>)} | ||||||
|  |       </div> | ||||||
|       <div className='d-flex'> |       <div className='d-flex'> | ||||||
|         <SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton> |         <SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton> | ||||||
|       </div> |       </div> | ||||||
|     </Form> |     </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,6 +60,7 @@ export default function ItemJob ({ item, toc, rank, children }) { | |||||||
|               </Link> |               </Link> | ||||||
|             </span> |             </span> | ||||||
|             {item.mine && |             {item.mine && | ||||||
|  |               ( | ||||||
|                 <> |                 <> | ||||||
|                   <wbr /> |                   <wbr /> | ||||||
|                   <span> \ </span> |                   <span> \ </span> | ||||||
| @ -68,11 +70,16 @@ export default function ItemJob ({ item, toc, rank, children }) { | |||||||
|                     </a> |                     </a> | ||||||
|                   </Link> |                   </Link> | ||||||
|                   {item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>} |                   {item.status !== 'ACTIVE' && <span className='ml-1 font-weight-bold text-boost'> {item.status}</span>} | ||||||
|  |                 </>)} | ||||||
|  |             {item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ml-1`}>PROMOTED</Badge>} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         {toc && | ||||||
|  |           <> | ||||||
|  |             <Share item={item} /> | ||||||
|  |             <Toc text={item.text} /> | ||||||
|           </>} |           </>} | ||||||
|       </div> |       </div> | ||||||
|         </div> |  | ||||||
|         {toc && <Toc text={item.text} />} |  | ||||||
|       </div> |  | ||||||
|       {children && ( |       {children && ( | ||||||
|         <div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}> |         <div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}> | ||||||
|           <div className='mb-3 d-flex'> |           <div className='mb-3 d-flex'> | ||||||
|  | |||||||
| @ -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 ( | ||||||
|     <> |  | ||||||
|       <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' /> |     <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,7 +7,8 @@ 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(() => { | ||||||
|  |     async function effect () { | ||||||
|       if (webLn) { |       if (webLn) { | ||||||
|         try { |         try { | ||||||
|           const provider = await requestProvider() |           const provider = await requestProvider() | ||||||
| @ -16,11 +17,13 @@ export default function LnQR ({ value, webLn, statusVariant, status }) { | |||||||
|           console.log(e.message) |           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,19 +55,20 @@ 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 />} | ||||||
|         <div className='text-center font-weight-bold text-muted pb-4'> |  | ||||||
|           Not registered? Just login, we'll automatically create an account. |  | ||||||
|         </div> |  | ||||||
|       {errorMessage && |       {errorMessage && | ||||||
|           <Alert variant='danger' onClose={() => setErrorMessage(undefined)} dismissible>{errorMessage}</Alert>} |         <Alert | ||||||
|         {router.query.type === 'lightning' |           variant='danger' | ||||||
|           ? <LightningAuth callbackUrl={callbackUrl} /> |           onClose={() => setErrorMessage(undefined)} | ||||||
|           : ( |           dismissible | ||||||
|             <> |         >{errorMessage} | ||||||
|  |         </Alert>} | ||||||
|       <Button |       <Button | ||||||
|         className={`mt-2 ${styles.providerButton}`} |         className={`mt-2 ${styles.providerButton}`} | ||||||
|         variant='primary' |         variant='primary' | ||||||
| @ -81,9 +81,9 @@ export default function Login ({ providers, callbackUrl, error, Header }) { | |||||||
|           width={20} |           width={20} | ||||||
|           height={20} |           height={20} | ||||||
|           className='mr-3' |           className='mr-3' | ||||||
|                 />Login with Lightning |         />{text || 'Login'} with Lightning | ||||||
|       </Button> |       </Button> | ||||||
|               {Object.values(providers).map(provider => { |       {providers && Object.values(providers).map(provider => { | ||||||
|         if (provider.name === 'Email' || provider.name === 'Lightning') { |         if (provider.name === 'Email' || provider.name === 'Lightning') { | ||||||
|           return null |           return null | ||||||
|         } |         } | ||||||
| @ -101,14 +101,13 @@ export default function Login ({ providers, callbackUrl, error, Header }) { | |||||||
|           > |           > | ||||||
|             <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,6 +88,15 @@ function Notification ({ n }) { | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|             ) |             ) | ||||||
|  |           : n.__typename === 'Referral' | ||||||
|  |             ? ( | ||||||
|  |               <> | ||||||
|  |                 <small className='font-weight-bold text-secondary ml-2'> | ||||||
|  |                   someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link> | ||||||
|  |                   <small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small> | ||||||
|  |                 </small> | ||||||
|  |               </> | ||||||
|  |               ) | ||||||
|             : n.__typename === 'InvoicePaid' |             : n.__typename === 'InvoicePaid' | ||||||
|               ? ( |               ? ( | ||||||
|                 <div className='font-weight-bold text-info ml-2 py-1'> |                 <div className='font-weight-bold text-info ml-2 py-1'> | ||||||
|  | |||||||
| @ -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>) | ||||||
|  | } | ||||||
| @ -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, ''))) | ||||||
| @ -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 (sats <= me.sats) { | ||||||
|                 if (existingSats === 0) { |                 if (existingSats === 0) { | ||||||
|                   setVoteShow(true) |                   setVoteShow(true) | ||||||
|                 } else { |                 } else { | ||||||
|                   setTipShow(true) |                   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 `})` | ||||||
|  |   }, | ||||||
|  |   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' | ||||||
|  | |||||||
							
								
								
									
										6335
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6335
									
								
								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
 | ||||||
|  |     if (router.isReady) { | ||||||
|       router.replace({ |       router.replace({ | ||||||
|         pathname: router.pathname, |         pathname: router.pathname, | ||||||
|         query: { ...router.query, nodata: true } |         query: { ...router.query, nodata: true } | ||||||
|       }, router.asPath, { ...router.options, scroll: false }) |       }, 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 /> |  | ||||||
|                     <ItemActProvider> |  | ||||||
|                       <ItemActModal /> |  | ||||||
|                     {data || !apollo?.query |                     {data || !apollo?.query | ||||||
|                       ? <Component {...props} /> |                       ? <Component {...props} /> | ||||||
|                       : <CSRWrapper Component={Component} {...props} />} |                       : <CSRWrapper Component={Component} {...props} />} | ||||||
|                     </ItemActProvider> |                   </ShowModalProvider> | ||||||
|                   </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,8 +24,17 @@ const options = { | |||||||
|         token.user = { id: Number(user.id) } |         token.user = { id: Number(user.id) } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       if (isNewUser) { | ||||||
|  |         // if referrer exists, set on user
 | ||||||
|  |         if (req.cookies.sn_referrer && user?.id) { | ||||||
|  |           const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } }) | ||||||
|  |           if (referrer) { | ||||||
|  |             await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } }) | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         // sign them up for the newsletter
 |         // sign them up for the newsletter
 | ||||||
|       if (isNewUser && profile.email) { |         if (profile.email) { | ||||||
|           fetch(process.env.LIST_MONK_URL + '/api/subscribers', { |           fetch(process.env.LIST_MONK_URL + '/api/subscribers', { | ||||||
|             method: 'POST', |             method: 'POST', | ||||||
|             headers: { |             headers: { | ||||||
| @ -43,6 +50,7 @@ const options = { | |||||||
|             }) |             }) | ||||||
|           }).then(async r => console.log(await r.json())).catch(console.log) |           }).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, | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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