feat: stream cards improvements

- edit toggle
- reorder with drag and drop
- delete button inside edit modal
- add ability to clear image from card
- make card textarea taller and avoid horizontal resize
This commit is contained in:
Alejandro Gomez
2023-07-28 08:09:55 +02:00
parent 3e2336129c
commit 4e4ea9efa6
12 changed files with 419 additions and 78 deletions

View File

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

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

@ -32,7 +32,13 @@
gap: 16px;
border-radius: 24px;
background: #111;
width: 210px;
min-width: 210px;
max-width: 310px;
}
.stream-card.image-card {
padding: 0;
background: transparent;
}
.stream-card .card-title {
@ -90,6 +96,8 @@
padding: 8px 16px;
border-radius: 16px;
margin-bottom: 8px;
resize: vertical;
min-height: 210px;
}
.form-control {
@ -126,3 +134,15 @@
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 {
width: 100%;
}

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

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

@ -0,0 +1,44 @@
import * as BaseToggle from "@radix-ui/react-toggle";
import "./toggle.css";
interface ToggleProps {
label: string;
}
function ToggleLeft(props) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path
fillRule="evenodd"
clipRule="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"
/>
</svg>
);
}
function ToggleRight(props) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path
fillRule="evenodd"
clipRule="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"
/>
</svg>
);
}
export function Toggle({ label, text, ...rest }: ToggleProps) {
const { pressed } = rest;
return (
<div className="toggle-container">
<BaseToggle.Root className="toggle" aria-label={label} {...rest}>
{pressed ? <ToggleRight /> : <ToggleLeft />}
</BaseToggle.Root>
<span className="toggle-text">{text}</span>
</div>
);
}

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;