From 7a6f4048fb12a076dacc36d9716334505c6a1cdd Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 26 Jul 2023 10:27:06 +0200 Subject: [PATCH 1/4] feat: cards --- package.json | 1 + public/icons.svg | 3 + src/const.ts | 2 + src/element/external-link.tsx | 7 + src/element/file-uploader.css | 27 ++ src/element/file-uploader.tsx | 75 ++++ src/element/follow-button.tsx | 6 +- src/element/markdown.css | 24 ++ src/element/markdown.tsx | 11 + src/element/stream-cards.css | 128 +++++++ src/element/stream-cards.tsx | 305 +++++++++++++++++ src/hooks/cards.ts | 86 +++++ src/index.css | 3 + src/pages/stream-page.tsx | 2 + src/utils.ts | 26 +- yarn.lock | 629 +++++++++++++++++++++++++++++++++- 16 files changed, 1324 insertions(+), 11 deletions(-) create mode 100644 src/element/external-link.tsx create mode 100644 src/element/file-uploader.css create mode 100644 src/element/file-uploader.tsx create mode 100644 src/element/markdown.css create mode 100644 src/element/markdown.tsx create mode 100644 src/element/stream-cards.css create mode 100644 src/element/stream-cards.tsx create mode 100644 src/hooks/cards.ts diff --git a/package.json b/package.json index 33b6026..e2e6237 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-intersection-observer": "^9.5.1", + "react-markdown": "^8.0.7", "react-router-dom": "^6.13.0", "react-tag-input-component": "^2.0.2", "semantic-sdp": "^3.26.2", diff --git a/public/icons.svg b/public/icons.svg index 1571504..965d0ec 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -54,5 +54,8 @@ + + + diff --git a/src/const.ts b/src/const.ts index 124f1c0..ca60be4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -3,3 +3,5 @@ import { EventKind } from "@snort/system"; export const LIVE_STREAM = 30_311 as EventKind; export const LIVE_STREAM_CHAT = 1_311 as EventKind; export const GOAL = 9041 as EventKind; +export const USER_CARDS = 17_777 as EventKind; +export const CARD = 37_777 as EventKind; diff --git a/src/element/external-link.tsx b/src/element/external-link.tsx new file mode 100644 index 0000000..5659b3a --- /dev/null +++ b/src/element/external-link.tsx @@ -0,0 +1,7 @@ +export function ExternalLink({ children, href }) { + return ( + + {children} + + ) +} diff --git a/src/element/file-uploader.css b/src/element/file-uploader.css new file mode 100644 index 0000000..bfbddfd --- /dev/null +++ b/src/element/file-uploader.css @@ -0,0 +1,27 @@ +.file-uploader-container { + display: flex; + justify-content: space-between; +} + +.file-uploader input[type="file"] { + display: none; +} + +.file-uploader { + align-self: flex-start; + background: white; + color: black; + max-width: 100px; + border-radius: 10px; + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.image-preview { + width: 82px; + height: 60px; + border-radius: 10px; +} diff --git a/src/element/file-uploader.tsx b/src/element/file-uploader.tsx new file mode 100644 index 0000000..a43006a --- /dev/null +++ b/src/element/file-uploader.tsx @@ -0,0 +1,75 @@ +import "./file-uploader.css"; +import { VoidApi } from "@void-cat/api"; +import { useState } from "react"; + +const voidCatHost = "https://void.cat"; +const fileExtensionRegex = /\.([\w]{1,7})$/i; +const voidCatApi = new VoidApi(voidCatHost); + +type UploadResult = { + url?: string; + error?: string; +}; + +async function voidCatUpload(file: File | Blob): Promise { + const uploader = voidCatApi.getUploader(file); + + const rsp = await uploader.upload({ + "V-Strip-Metadata": "true", + }); + if (rsp.ok) { + let ext = file.name.match(fileExtensionRegex); + if (rsp.file?.metadata?.mimeType === "image/webp") { + ext = ["", "webp"]; + } + const resultUrl = + rsp.file?.metadata?.url ?? + `${voidCatHost}/d/${rsp.file?.id}${ext ? `.${ext[1]}` : ""}`; + + const ret = { + url: resultUrl, + } as UploadResult; + + return ret; + } else { + return { + error: rsp.errorMessage, + }; + } +} + +export function FileUploader({ onFileUpload }) { + const [img, setImg] = useState(); + const [isUploading, setIsUploading] = useState(false); + + async function onFileChange(ev) { + const file = ev.target.files[0]; + if (file) { + try { + setIsUploading(true); + const upload = await voidCatUpload(file); + if (upload.url) { + setImg(upload.url); + onFileUpload(upload.url); + } + if (upload.error) { + console.error(upload.error); + } + } catch (error) { + console.error(error); + } finally { + setIsUploading(false); + } + } + } + + return ( +
+ + {img && } +
+ ); +} diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index bf6bee4..260343f 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -13,8 +13,8 @@ export function LoggedInFollowButton({ }) { const login = useLogin(); const following = useFollows(loggedIn, true); - const { tags, relays } = following ? following : { tags: [], relays: {} } - const follows = tags.filter((t) => t.at(0) === "p") + const { tags, relays } = following ? following : { tags: [], relays: {} }; + const follows = tags.filter((t) => t.at(0) === "p"); const isFollowing = follows.find((t) => t.at(1) === pubkey); async function unfollow() { @@ -23,7 +23,7 @@ export function LoggedInFollowButton({ const ev = await pub.generic((eb) => { eb.kind(EventKind.ContactList).content(JSON.stringify(relays)); for (const t of tags) { - const isFollow = t.at(0) === "p" && t.at(1) === pubkey + const isFollow = t.at(0) === "p" && t.at(1) === pubkey; if (!isFollow) { eb.tag(t); } diff --git a/src/element/markdown.css b/src/element/markdown.css new file mode 100644 index 0000000..83826de --- /dev/null +++ b/src/element/markdown.css @@ -0,0 +1,24 @@ +.markdown a { + color: var(--text-link); +} + +.markdown ul, .markdown ol { + margin: 0; + padding: 0 12px; + font-size: 18px; + font-weight: 400; + line-height: 29px; +} + +.markdown p { + font-size: 18px; + font-style: normal; + overflow-wrap: break-word; + font-weight: 400; + line-height: 29px; /* 161.111% */ +} + +.markdown img { + max-height: 230px; + width: 100%; +} diff --git a/src/element/markdown.tsx b/src/element/markdown.tsx new file mode 100644 index 0000000..0af282b --- /dev/null +++ b/src/element/markdown.tsx @@ -0,0 +1,11 @@ +import "./markdown.css"; + +import ReactMarkdown from "react-markdown"; + +export function Markdown({ children }) { + return ( +
+ +
+ ); +} diff --git a/src/element/stream-cards.css b/src/element/stream-cards.css new file mode 100644 index 0000000..37bfdbd --- /dev/null +++ b/src/element/stream-cards.css @@ -0,0 +1,128 @@ +.stream-cards { + display: none; +} + +@media (min-width: 1020px) { + .stream-cards { + display: flex; + align-items: flex-start; + gap: 24px; + margin-top: 12px; + flex-wrap: wrap; + } +} + +.card-container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.editor-buttons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stream-card { + display: flex; + align-self: flex-start; + flex-direction: column; + padding: 20px 24px; + gap: 16px; + border-radius: 24px; + background: #111; + width: 210px; +} + +.stream-card .card-title { + margin: 0; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +@media (min-width: 1900px) { + .stream-card { + width: 342px; + } +} + +.add-card { + align-items: center; + justify-content: center; +} + +.add-card .add-icon { + color: #797979; + cursor: pointer; + width: 24px; + height: 24px; +} + +.new-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.new-card h3 { + margin: 0; + margin-bottom: 12px; +} + +.new-card input[type="text"] { + background: #262626; + padding: 8px 16px; + border-radius: 16px; + width: unset; + margin-bottom: 8px; + font-size: 16px; + font-weight: 500; + line-height: 20px; +} + + +.new-card textarea { + width: unset; + background: #262626; + padding: 8px 16px; + border-radius: 16px; + margin-bottom: 8px; +} + +.form-control { + display: flex; + flex-direction: column; +} + +.form-control label { + margin-bottom: 8px; +} + +.new-card-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.help-text { + color: var(--text-muted); + font-size: 14px; + margin-left: 6px; +} + +.help-text a { + color: var(--text-link); +} + +.add-button { + height: 50px; +} + +.delete-button { + background: transparent; + color: var(--text-danger); +} diff --git a/src/element/stream-cards.tsx b/src/element/stream-cards.tsx new file mode 100644 index 0000000..608beba --- /dev/null +++ b/src/element/stream-cards.tsx @@ -0,0 +1,305 @@ +import "./stream-cards.css"; + +import { useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; + +import type { NostrEvent } from "@snort/system"; + +import { useLogin } from "hooks/login"; +import { useCards } from "hooks/cards"; +import { CARD, USER_CARDS } from "const"; +import { toTag } from "utils"; +import { System } from "index"; +import { findTag } from "utils"; +import { Icon } from "./icon"; +import { ExternalLink } from "./external-link"; +import { FileUploader } from "./file-uploader"; +import { Markdown } from "./markdown"; + +interface CardType { + identifier?: string; + title?: string; + image?: string; + link?: string; + content: string; +} + +interface CardProps { + canEdit?: boolean; + ev: NostrEvent; + cards: NostrEvent[]; +} + +function Card({ canEdit, ev, cards }: CardProps) { + const identifier = findTag(ev, "d"); + const title = findTag(ev, "title") || findTag(ev, "subject"); + const image = findTag(ev, "image"); + const link = findTag(ev, "r"); + const evCard = { title, image, link, content: ev.content, identifier }; + + const card = ( + <> +
+ {title &&

{title}

} + {image && {title}} + +
+ + ); + const editor = canEdit && ( +
+ + +
+ ); + return link && !canEdit ? ( +
+ {card} + {editor} +
+ ) : ( +
+ {card} + {editor} +
+ ); +} + +interface CardDialogProps { + header?: string; + cta?: string; + card?: CardType; + onSave(ev: CardType): void; + onCancel(): void; +} + +function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) { + const [title, setTitle] = useState(card?.title ?? ""); + const [image, setImage] = useState(card?.image ?? ""); + const [content, setContent] = useState(card?.content ?? ""); + const [link, setLink] = useState(card?.link ?? ""); + + return ( +
+

{header || "Add card"}

+
+ + setTitle(e.target.value)} + placeholder="e.g. about me" + /> +
+
+ + +
+
+ + setLink(e.target.value)} + /> +
+
+ +