diff --git a/package.json b/package.json index e2e6237..3353ba2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toggle": "^1.0.3", "@react-hook/resize-observer": "^1.2.6", "@scure/base": "^1.1.1", "@snort/shared": "^1.0.4", @@ -31,6 +32,8 @@ "qr-code-styling": "^1.6.0-rc.1", "react": "^18.2.0", "react-confetti": "^6.1.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-intersection-observer": "^9.5.1", diff --git a/public/icons.svg b/public/icons.svg index 965d0ec..291ba8d 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -57,5 +57,21 @@ + + + + + + diff --git a/src/element/Event.tsx b/src/element/Event.tsx index bb43c08..28044fd 100644 --- a/src/element/Event.tsx +++ b/src/element/Event.tsx @@ -29,5 +29,5 @@ export function Event({ link }: EventProps) { ); } - return {link.id}; + return null; } diff --git a/src/element/event.css b/src/element/event.css index 42f3b97..33cfcd3 100644 --- a/src/element/event.css +++ b/src/element/event.css @@ -1,3 +1,7 @@ +.event-container .note { + max-width: 320px; +} + .event-container .goal { font-size: 14px; } diff --git a/src/element/file-uploader.css b/src/element/file-uploader.css index bfbddfd..71e6ce7 100644 --- a/src/element/file-uploader.css +++ b/src/element/file-uploader.css @@ -25,3 +25,13 @@ height: 60px; border-radius: 10px; } + +.file-uploader-preview { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.file-uploader-preview .clear-button { + color: var(--text-danger); +} diff --git a/src/element/file-uploader.tsx b/src/element/file-uploader.tsx index a43006a..5226e86 100644 --- a/src/element/file-uploader.tsx +++ b/src/element/file-uploader.tsx @@ -38,8 +38,8 @@ async function voidCatUpload(file: File | Blob): Promise { } } -export function FileUploader({ onFileUpload }) { - const [img, setImg] = useState(); +export function FileUploader({ defaultImage, onClear, onFileUpload }) { + const [img, setImg] = useState(defaultImage); const [isUploading, setIsUploading] = useState(false); async function onFileChange(ev) { @@ -63,13 +63,25 @@ export function FileUploader({ onFileUpload }) { } } + function clearImage() { + setImg(""); + onClear(); + } + return (
- {img && } +
+ {img?.length > 0 && ( + + )} + {img && } +
); } diff --git a/src/element/stream-cards.css b/src/element/stream-cards.css index 37bfdbd..f7f5eed 100644 --- a/src/element/stream-cards.css +++ b/src/element/stream-cards.css @@ -4,14 +4,27 @@ @media (min-width: 1020px) { .stream-cards { - display: flex; + display: grid; align-items: flex-start; - gap: 24px; + grid-template-columns: repeat(2, 1fr); + gap: 32px; margin-top: 12px; flex-wrap: wrap; } } +@media (min-width: 1600px) { + .stream-cards { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 2100px) { + .stream-cards { + grid-template-columns: repeat(4, 1fr); + } +} + .card-container { display: flex; flex-direction: column; @@ -28,11 +41,14 @@ display: flex; align-self: flex-start; flex-direction: column; - padding: 20px 24px; gap: 16px; - border-radius: 24px; - background: #111; - width: 210px; + flex: 1; + width: 100%; +} + +.stream-card.image-card { + padding: 0; + background: transparent; } .stream-card .card-title { @@ -83,13 +99,14 @@ line-height: 20px; } - .new-card textarea { width: unset; background: #262626; padding: 8px 16px; border-radius: 16px; margin-bottom: 8px; + resize: vertical; + min-height: 210px; } .form-control { @@ -126,3 +143,29 @@ background: transparent; color: var(--text-danger); } + +@keyframes shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(5deg); + } + 50% { + transform: rotate(0eg); + } + 75% { + transform: rotate(-5deg); + } + 100% { + transform: rotate(0deg); + } +} + +.stream-card .card-image { + max-width: 343px; +} + +.stream-card { + max-width: 343px; +} diff --git a/src/element/stream-cards.tsx b/src/element/stream-cards.tsx index 608beba..9a53cd9 100644 --- a/src/element/stream-cards.tsx +++ b/src/element/stream-cards.tsx @@ -1,10 +1,13 @@ import "./stream-cards.css"; -import { useState } from "react"; +import { useState, forwardRef } from "react"; import * as Dialog from "@radix-ui/react-dialog"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import type { NostrEvent } from "@snort/system"; +import { Toggle } from "element/toggle"; import { useLogin } from "hooks/login"; import { useCards } from "hooks/cards"; import { CARD, USER_CARDS } from "const"; @@ -30,50 +33,150 @@ interface CardProps { cards: NostrEvent[]; } +function isEmpty(s?: string) { + return !s || s.trim().length === 0; +} + +const CardPreview = forwardRef( + ({ style, title, link, image, content }, ref) => { + const isImageOnly = !isEmpty(image) && isEmpty(content) && isEmpty(title); + return ( +
+ {title &&

{title}

} + {image && + (link?.length > 0 ? ( + + {title} + + ) : ( + {title} + ))} + +
+ ); + }, +); + function Card({ canEdit, ev, cards }: CardProps) { + const login = useLogin(); 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 content = ev.content; + const evCard = { title, image, link, content, identifier }; + const tags = cards.map(toTag); + const [style, dragRef] = useDrag( + () => ({ + type: "card", + item: { identifier }, + canDrag: () => { + return canEdit; + }, + collect: (monitor) => { + const isDragging = monitor.isDragging(); + return { + opacity: isDragging ? 0.1 : 1, + cursor: !canEdit ? "auto" : isDragging ? "grabbing" : "grab", + }; + }, + }), + [canEdit, identifier], + ); + + function findTagByIdentifier(d) { + return tags.find((t) => t.at(1).endsWith(`:${d}`)); + } + + const [dropStyle, dropRef] = useDrop( + () => ({ + accept: ["card"], + canDrop: () => { + return canEdit; + }, + collect: (monitor) => { + const isOvering = monitor.isOver({ shallow: true }); + return { + opacity: isOvering ? 0.3 : 1, + animation: isOvering ? "shake 0.1s 3" : "", + }; + }, + async drop(item) { + if (identifier === item.identifier) { + return; + } + const newItem = findTagByIdentifier(item.identifier); + const oldItem = findTagByIdentifier(identifier); + const newTags = tags.map((t) => { + if (t === oldItem) { + return newItem; + } + if (t === newItem) { + return oldItem; + } + return t; + }); + const pub = login?.publisher(); + const userCardsEv = await pub.generic((eb) => { + eb.kind(USER_CARDS).content(""); + for (const tag of newTags) { + eb.tag(tag); + } + return eb; + }); + console.debug(userCardsEv); + System.BroadcastEvent(userCardsEv); + }, + }), + [canEdit, tags, identifier], + ); const card = ( - <> -
- {title &&

{title}

} - {image && {title}} - -
- + ); const editor = canEdit && (
- - +
); - return link && !canEdit ? ( -
- {card} - {editor} -
- ) : ( -
+ return canEdit ? ( +
{card} {editor}
+ ) : ( +
{card}
); } interface CardDialogProps { header?: string; cta?: string; + cancelCta?: string; card?: CardType; onSave(ev: CardType): void; onCancel(): void; } -function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) { +function CardDialog({ + header, + cta, + cancelCta, + card, + onSave, + onCancel, +}: CardDialogProps) { const [title, setTitle] = useState(card?.title ?? ""); const [image, setImage] = useState(card?.image ?? ""); const [content, setContent] = useState(card?.content ?? ""); @@ -94,10 +197,14 @@ function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) {
- + setImage("")} + />
- +
@@ -137,11 +244,14 @@ function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) { interface EditCardProps { card: CardType; + cards: NostrEvent[]; } -function EditCard({ card }: EditCardProps) { +function EditCard({ card, cards }: EditCardProps) { const login = useLogin(); const [isOpen, setIsOpen] = useState(false); + const identifier = card.identifier; + const tags = cards.map(toTag); async function editCard({ title, image, link, content }) { const pub = login?.publisher(); @@ -165,8 +275,23 @@ function EditCard({ card }: EditCardProps) { } } - function onCancel() { - setIsOpen(false); + async function onCancel() { + const pub = login?.publisher(); + if (pub) { + const userCardsEv = await pub.generic((eb) => { + eb.kind(USER_CARDS).content(""); + for (const tag of tags) { + if (!tag.at(1).endsWith(`:${identifier}`)) { + eb.tag(tag); + } + } + return eb; + }); + + console.debug(userCardsEv); + System.BroadcastEvent(userCardsEv); + setIsOpen(false); + } } return ( @@ -180,6 +305,7 @@ function EditCard({ card }: EditCardProps) { { - eb.kind(USER_CARDS).content(""); - for (const tag of tags) { - if (tag.at(1) !== toTag(card).at(1)) { - eb.tag(tag); - } - } - return eb; - }); - - console.log(userCardsEv); - - System.BroadcastEvent(userCardsEv); - } - } - - return ( - - ); -} - interface AddCardProps { cards: NostrEvent[]; } @@ -294,12 +385,26 @@ export function StreamCards({ host }) { const login = useLogin(); const canEdit = login?.pubkey === host; const cards = useCards(host, canEdit); - return ( -
- {cards.map((ev) => ( - - ))} - {canEdit && } -
+ const [isEditing, setIsEditing] = useState(false); + const components = ( + <> +
+ {cards.map((ev) => ( + + ))} + {isEditing && } +
+ {canEdit && ( +
+ +
+ )} + ); + return {components}; } diff --git a/src/element/toggle.css b/src/element/toggle.css new file mode 100644 index 0000000..303173a --- /dev/null +++ b/src/element/toggle.css @@ -0,0 +1,27 @@ +.toggle-container { + display: flex; + align-items: center; + gap: 6px; +} + +.toggle { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; +} +.toggle svg { + color: var(--text-muted); + height: 32px; + width: 32px; +} +.toggle:hover { + cursor: pointer; +} +.toggle:hover svg { + color: white; +} +.toggle[data-state='on'] svg { + color: var(--text-link); +} diff --git a/src/element/toggle.tsx b/src/element/toggle.tsx new file mode 100644 index 0000000..e93f6d9 --- /dev/null +++ b/src/element/toggle.tsx @@ -0,0 +1,19 @@ +import * as BaseToggle from "@radix-ui/react-toggle"; +import "./toggle.css"; +import { Icon } from "element/icon"; + +interface ToggleProps { + label: string; +} + +export function Toggle({ label, text, ...rest }: ToggleProps) { + const { pressed } = rest; + return ( +
+ + {pressed ? : } + + {text} +
+ ); +} diff --git a/src/hooks/follows.ts b/src/hooks/follows.ts index d006b60..a9f3921 100644 --- a/src/hooks/follows.ts +++ b/src/hooks/follows.ts @@ -18,9 +18,9 @@ export default function useFollows(pubkey: string, leaveOpen = false) { const { data } = useRequestBuilder( System, ReplaceableNoteStore, - sub + sub, ); - const relays = JSON.parse(data?.content ?? "{}"); - return data ? { tags: data.tags, relays } : null + const relays = JSON.parse(data?.content.length > 0 ? data?.content : "{}"); + return data ? { tags: data.tags, relays } : null; } diff --git a/src/pages/layout.css b/src/pages/layout.css index abc9ec0..e995604 100644 --- a/src/pages/layout.css +++ b/src/pages/layout.css @@ -163,3 +163,7 @@ button span.hide-on-mobile { .age-check .btn { padding: 12px 16px; } + +.profile-menu { + cursor: pointer; +} diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx index f691cff..fee8b89 100644 --- a/src/pages/layout.tsx +++ b/src/pages/layout.tsx @@ -27,7 +27,7 @@ export function LayoutPage() { +
+
Home - zap.stream @@ -92,9 +96,7 @@ export function LayoutPage() {
-
- {/* Future menu items go here */} -
+
{/* Future menu items go here */}
{loggedIn()} {loggedOut()} diff --git a/src/pages/profile-page.css b/src/pages/profile-page.css index 566735f..a15a93b 100644 --- a/src/pages/profile-page.css +++ b/src/pages/profile-page.css @@ -43,6 +43,11 @@ left: 120px; } +.profile-page .status-indicator .offline { + margin-top: 8px; + margin-left: 16px; +} + .profile-page .profile-actions { position: absolute; display: flex; diff --git a/src/pages/stream-page.css b/src/pages/stream-page.css index 24923e8..d292c90 100644 --- a/src/pages/stream-page.css +++ b/src/pages/stream-page.css @@ -12,6 +12,12 @@ gap: var(--gap-s); display: flex; flex-direction: column; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.stream-page .video-content::-webkit-scrollbar { + display: none; } .stream-page .video-overlay { diff --git a/yarn.lock b/yarn.lock index c677d5d..11db2c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2311,6 +2311,28 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-toggle@npm:^1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-toggle@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-controllable-state": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: ed5407f48254f20cda542017774f259d0b2c0007ea4bd7287d10d751016dbf269cb13d1142591432c269c3ab768cde2f1ba0344743027d36bbec10af909f19de + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" @@ -2373,6 +2395,27 @@ __metadata: languageName: node linkType: hard +"@react-dnd/asap@npm:^5.0.1": + version: 5.0.2 + resolution: "@react-dnd/asap@npm:5.0.2" + checksum: 18f040e53512983f11c542ef21e6e4cac605d585a10cd764b13bc1b2f3ac7490e0fa40503adc348d8387aa45bc8e7eebe9cb33003b960a30bb5fde666ff2adde + languageName: node + linkType: hard + +"@react-dnd/invariant@npm:^4.0.1": + version: 4.0.2 + resolution: "@react-dnd/invariant@npm:4.0.2" + checksum: 594f6d78896c19bb8f023e101334fd91a9fdff686117bd8e830ba53737ec0a6042dab66971d3d63c7afbc622103909aff7a64c5c6767e0aa8d9561fd42705016 + languageName: node + linkType: hard + +"@react-dnd/shallowequal@npm:^4.0.1": + version: 4.0.2 + resolution: "@react-dnd/shallowequal@npm:4.0.2" + checksum: 7f21d691bddbfd4d2830948cbeefecca1600b2b46bcb1934926795f07ae8a1fa60a3dfd3a2112be5ef682c3820c80a99711e9fa15843f7e300acb25a4ecb70ab + languageName: node + linkType: hard + "@react-hook/latest@npm:^1.0.2": version: 1.0.3 resolution: "@react-hook/latest@npm:1.0.3" @@ -4897,6 +4940,17 @@ __metadata: languageName: node linkType: hard +"dnd-core@npm:^16.0.1": + version: 16.0.1 + resolution: "dnd-core@npm:16.0.1" + dependencies: + "@react-dnd/asap": ^5.0.1 + "@react-dnd/invariant": ^4.0.1 + redux: ^4.2.0 + checksum: b7d3ef4664f433af796f440ddd27ad9d7fef0205f26c4b7c0af6ebf612ffa9b33e64d095d3e79190c4baaed34aa36570f321ebe0d2cc8ff1031ff158a0907b3f + languageName: node + linkType: hard + "dns-equal@npm:^1.0.0": version: 1.0.0 resolution: "dns-equal@npm:1.0.0" @@ -6133,6 +6187,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.2": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: ^16.7.0 + checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 + languageName: node + linkType: hard + "hpack.js@npm:^2.1.6": version: 2.1.6 resolution: "hpack.js@npm:2.1.6" @@ -8846,6 +8909,40 @@ __metadata: languageName: node linkType: hard +"react-dnd-html5-backend@npm:^16.0.1": + version: 16.0.1 + resolution: "react-dnd-html5-backend@npm:16.0.1" + dependencies: + dnd-core: ^16.0.1 + checksum: e2368bf85d5632a5cd867b743feb54c9052d909ea5331608860fa455edf3c633ac791f5b338e3db29b19ea8670c0ba5fb43c9c1c2510760bea030811d726cdfa + languageName: node + linkType: hard + +"react-dnd@npm:^16.0.1": + version: 16.0.1 + resolution: "react-dnd@npm:16.0.1" + dependencies: + "@react-dnd/invariant": ^4.0.1 + "@react-dnd/shallowequal": ^4.0.1 + dnd-core: ^16.0.1 + fast-deep-equal: ^3.1.3 + hoist-non-react-statics: ^3.3.2 + peerDependencies: + "@types/hoist-non-react-statics": ">= 3.3.1" + "@types/node": ">= 12" + "@types/react": ">= 16" + react: ">= 16.14" + peerDependenciesMeta: + "@types/hoist-non-react-statics": + optional: true + "@types/node": + optional: true + "@types/react": + optional: true + checksum: e8da2186aaafcd5bb41c090a995c963a7c3c73c20991667a2cfc0c800d7f7f73913414b2e61c437cdb6221bb2151bd5174088b8b42c17056a896fc4d1da5729f + languageName: node + linkType: hard + "react-dom@npm:^18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -8888,7 +8985,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -9103,6 +9200,15 @@ __metadata: languageName: node linkType: hard +"redux@npm:^4.2.0": + version: 4.2.1 + resolution: "redux@npm:4.2.1" + dependencies: + "@babel/runtime": ^7.9.2 + checksum: f63b9060c3a1d930ae775252bb6e579b42415aee7a23c4114e21a0b4ba7ec12f0ec76936c00f546893f06e139819f0e2855e0d55ebfce34ca9c026241a6950dd + languageName: node + linkType: hard + "regenerate-unicode-properties@npm:^10.1.0": version: 10.1.0 resolution: "regenerate-unicode-properties@npm:10.1.0" @@ -9881,6 +9987,7 @@ __metadata: "@radix-ui/react-dialog": ^1.0.4 "@radix-ui/react-progress": ^1.0.3 "@radix-ui/react-tabs": ^1.0.4 + "@radix-ui/react-toggle": ^1.0.3 "@react-hook/resize-observer": ^1.2.6 "@scure/base": ^1.1.1 "@snort/shared": ^1.0.4 @@ -9923,6 +10030,8 @@ __metadata: qr-code-styling: ^1.6.0-rc.1 react: ^18.2.0 react-confetti: ^6.1.0 + react-dnd: ^16.0.1 + react-dnd-html5-backend: ^16.0.1 react-dom: ^18.2.0 react-helmet: ^6.1.0 react-intersection-observer: ^9.5.1