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:
@ -29,5 +29,5 @@ export function Event({ link }: EventProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return <code>{link.id}</code>;
|
||||
return null;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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%;
|
||||
}
|
||||
|
@ -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
27
src/element/toggle.css
Normal 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
44
src/element/toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -163,3 +163,7 @@ button span.hide-on-mobile {
|
||||
.age-check .btn {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.profile-menu {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -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()}
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user