Merge pull request 'feat: stream cards improvements' (#47) from cards-improvements into main

Reviewed-on: Kieran/stream#47
This commit is contained in:
Kieran 2023-07-31 21:31:30 +00:00
commit 1229eee15e
16 changed files with 452 additions and 87 deletions

View File

@ -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",

View File

@ -57,5 +57,21 @@
<symbol id="plus" viewBox="0 0 24 24" fill="none">
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol>
<symbol id="toggle-off" viewBox="0 0 24 24" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 5C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5H8ZM12 12C12 14.2091 10.2091 16 8 16C5.79086 16 4 14.2091 4 12C4 9.79086 5.79086 8 8 8C10.2091 8 12 9.79086 12 12Z"
fill="currentColor"
/>
</symbol>
<symbol id="toggle-on" viewBox="0 0 24 24" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16 5C19.866 5 23 8.13401 23 12C23 15.866 19.866 19 16 19H8C4.13401 19 1 15.866 1 12C1 8.13401 4.13401 5 8 5H16ZM12 12C12 14.2091 13.7909 16 16 16C18.2091 16 20 14.2091 20 12C20 9.79086 18.2091 8 16 8C13.7909 8 12 9.79086 12 12Z"
fill="currentColor"
/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -29,5 +29,5 @@ export function Event({ link }: EventProps) {
);
}
return <code>{link.id}</code>;
return null;
}

View File

@ -1,3 +1,7 @@
.event-container .note {
max-width: 320px;
}
.event-container .goal {
font-size: 14px;
}

View File

@ -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);
}

View File

@ -38,8 +38,8 @@ async function voidCatUpload(file: File | Blob): Promise<UploadResult> {
}
}
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 (
<div className="file-uploader-container">
<label className="file-uploader">
<input type="file" onChange={onFileChange} />
{isUploading ? "Uploading..." : "Add File"}
</label>
{img && <img className="image-preview" src={img} />}
<div className="file-uploader-preview">
{img?.length > 0 && (
<button className="btn btn-primary clear-button" onClick={clearImage}>
Clear
</button>
)}
{img && <img className="image-preview" src={img} />}
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<div
className={`stream-card ${isImageOnly ? "image-card" : ""}`}
ref={ref}
style={style}
>
{title && <h1 className="card-title">{title}</h1>}
{image &&
(link?.length > 0 ? (
<ExternalLink href={link}>
<img className="card-image" src={image} alt={title} />
</ExternalLink>
) : (
<img className="card-image" src={image} alt={title} />
))}
<Markdown children={content} />
</div>
);
},
);
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 = (
<>
<div className="stream-card">
{title && <h1 className="card-title">{title}</h1>}
{image && <img src={image} alt={title} />}
<Markdown children={ev.content} />
</div>
</>
<CardPreview
ref={dropRef}
title={title}
link={link}
image={image}
content={content}
style={dropStyle}
/>
);
const editor = canEdit && (
<div className="editor-buttons">
<EditCard card={evCard} />
<DeleteCard card={ev} cards={cards} />
<EditCard card={evCard} cards={cards} />
</div>
);
return link && !canEdit ? (
<div className="card-container">
<ExternalLink href={link}>{card}</ExternalLink>
{editor}
</div>
) : (
<div className="card-container">
return canEdit ? (
<div className="card-container" ref={dragRef} style={style}>
{card}
{editor}
</div>
) : (
<div className="card-container">{card}</div>
);
}
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) {
</div>
<div className="form-control">
<label for="card-image">Image</label>
<FileUploader onFileUpload={setImage} />
<FileUploader
defaultImage={image}
onFileUpload={setImage}
onClear={() => setImage("")}
/>
</div>
<div className="form-control">
<label for="card-image-link">Link</label>
<label for="card-image-link">Image Link</label>
<input
id="card-image-link"
type="text"
@ -128,7 +235,7 @@ function CardDialog({ header, cta, card, onSave, onCancel }: CardDialogProps) {
{cta || "Add Card"}
</button>
<button className="btn delete-button" onClick={onCancel}>
Cancel
{cancelCta || "Cancel"}
</button>
</div>
</div>
@ -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) {
<CardDialog
header="Edit card"
cta="Save Card"
cancelCta="Delete"
card={card}
onSave={editCard}
onCancel={onCancel}
@ -190,41 +316,6 @@ function EditCard({ card }: EditCardProps) {
);
}
interface DeleteCardProps {
card: NostrEvent;
cards: NostrEvent[];
}
function DeleteCard({ card, cards }: DeleteCardProps) {
const login = useLogin();
const tags = cards.map(toTag);
async function deleteCard() {
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) !== toTag(card).at(1)) {
eb.tag(tag);
}
}
return eb;
});
console.log(userCardsEv);
System.BroadcastEvent(userCardsEv);
}
}
return (
<button className="btn delete-button" onClick={deleteCard}>
Delete
</button>
);
}
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 (
<div className="stream-cards">
{cards.map((ev) => (
<Card canEdit={canEdit} cards={cards} key={ev.id} ev={ev} />
))}
{canEdit && <AddCard cards={cards} />}
</div>
const [isEditing, setIsEditing] = useState(false);
const components = (
<>
<div className="stream-cards">
{cards.map((ev) => (
<Card canEdit={isEditing} cards={cards} key={ev.id} ev={ev} />
))}
{isEditing && <AddCard cards={cards} />}
</div>
{canEdit && (
<div className="edit-container">
<Toggle
pressed={isEditing}
onPressedChange={setIsEditing}
label="Toggle edit mode"
text="Edit cards"
/>
</div>
)}
</>
);
return <DndProvider backend={HTML5Backend}>{components}</DndProvider>;
}

27
src/element/toggle.css Normal file
View File

@ -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);
}

19
src/element/toggle.tsx Normal file
View File

@ -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 (
<div className="toggle-container">
<BaseToggle.Root className="toggle" aria-label={label} {...rest}>
{pressed ? <Icon name="toggle-on" /> : <Icon name="toggle-off" />}
</BaseToggle.Root>
<span className="toggle-text">{text}</span>
</div>
);
}

View File

@ -18,9 +18,9 @@ export default function useFollows(pubkey: string, leaveOpen = false) {
const { data } = useRequestBuilder<ReplaceableNoteStore>(
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;
}

View File

@ -163,3 +163,7 @@ button span.hide-on-mobile {
.age-check .btn {
padding: 12px 16px;
}
.profile-menu {
cursor: pointer;
}

View File

@ -27,7 +27,7 @@ export function LayoutPage() {
<Menu
menuClassName="ctx-menu"
menuButton={
<div>
<div className="profile-menu">
<Profile
avatarClassname="mb-squared"
pubkey={login.pubkey}
@ -82,7 +82,11 @@ export function LayoutPage() {
}
return (
<div className={`page${location.pathname.startsWith("/naddr1") ? " stream" : ""}`}>
<div
className={`page${
location.pathname.startsWith("/naddr1") ? " stream" : ""
}`}
>
<Helmet>
<title>Home - zap.stream</title>
</Helmet>
@ -92,9 +96,7 @@ export function LayoutPage() {
<input className="search-input" type="text" placeholder="Search" />
<Icon name="search" size={15} />
</div>
<div className="f-grow">
{/* Future menu items go here */}
</div>
<div className="f-grow">{/* Future menu items go here */}</div>
<div className="header-right">
{loggedIn()}
{loggedOut()}

View File

@ -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;

View File

@ -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 {

111
yarn.lock
View File

@ -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