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

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

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 {