diff --git a/assets/css/app.css b/assets/css/app.css index 6e4a931..a8e1812 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -734,3 +734,47 @@ html, body { border-radius: var(--mx-radius-sm); display: block; } + +/* ── Context Menu ── */ +.mx-context-menu { + position: fixed; + z-index: 300; + min-width: 160px; + background: var(--mx-surface2); + border: 1px solid var(--mx-border2); + border-radius: var(--mx-radius-sm); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.2); + overflow: hidden; + padding: 4px 0; + animation: mx-ctx-in 0.1s ease; +} + +@keyframes mx-ctx-in { + from { opacity: 0; transform: scale(0.96) translateY(-4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.mx-context-menu-item { + display: flex; + align-items: center; + width: 100%; + padding: 0.45rem 0.875rem; + background: none; + border: none; + color: var(--mx-fg); + font-size: 0.875rem; + font-family: inherit; + cursor: pointer; + text-align: left; + transition: background 0.1s; +} + +.mx-context-menu-item:hover { + background: color-mix(in oklch, var(--mx-accent) 14%, transparent); +} + +.mx-context-menu-separator { + height: 1px; + background: var(--mx-border); + margin: 4px 0; +} diff --git a/assets/js/index.tsx b/assets/js/index.tsx index f7ed4e3..312405b 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -55,6 +55,75 @@ function getAssetHost(): string { return appEl?.dataset.assetHost ?? "http://localhost:9000"; } +// ── Context menu ────────────────────────────────────────────────────────────── + +type ContextMenuItem = + | { type: "item"; label: string; onClick: () => void } + | { type: "separator" }; + +function ContextMenu({ + x, + y, + items, + onClose, +}: { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + function handleMouseDown(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + const itemCount = items.filter((i) => i.type === "item").length; + const sepCount = items.filter((i) => i.type === "separator").length; + const menuH = itemCount * 34 + sepCount * 9 + 8; + const menuW = 180; + const left = Math.min(x, window.innerWidth - menuW - 8); + const top = Math.min(y, window.innerHeight - menuH - 8); + + return createPortal( +
e.preventDefault()} + > + {items.map((item, i) => + item.type === "separator" ? ( +
+ ) : ( + + ) + )} +
, + document.body + ); +} + // ── Components ───────────────────────────────────────────────────────────────── function Spinner() { @@ -319,8 +388,43 @@ function TweetCard({ tweet }: { tweet: Tweet }) { const [editText, setEditText] = useState(tweet.content); const [error, setError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const qc = useQueryClient(); + const tweetUrl = `${window.location.origin}/feed/${tweet.id}`; + + const ctxItems: ContextMenuItem[] = canModify + ? [ + { + type: "item", + label: "Edit", + onClick: () => { + setEditText(tweet.content); + setEditing(true); + setConfirmDelete(false); + }, + }, + { type: "separator" }, + { + type: "item", + label: "Share", + onClick: () => navigator.clipboard.writeText(tweetUrl), + }, + ] + : [ + { + type: "item", + label: "View", + onClick: () => { window.location.href = tweetUrl; }, + }, + { type: "separator" }, + { + type: "item", + label: "Share", + onClick: () => navigator.clipboard.writeText(tweetUrl), + }, + ]; + const deleteMutation = useMutation({ mutationFn: async () => { const res = await destroyTweet({ @@ -379,6 +483,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) { className="mx-tweet" style={{ cursor: "pointer" }} onClick={() => { window.location.href = `/feed/${tweet.id}`; }} + onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }} >
M @@ -492,6 +597,14 @@ function TweetCard({ tweet }: { tweet: Tweet }) { {error && !editing &&

{error}

}
+ {ctxMenu && ( + setCtxMenu(null)} + /> + )} ); } @@ -775,11 +888,24 @@ function RefreshButton() { } function UserCard({ user }: { user: User }) { + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); + + const userUrl = `${window.location.origin}/users/${user.id}`; + + const ctxItems: ContextMenuItem[] = [ + { + type: "item", + label: "Share", + onClick: () => navigator.clipboard.writeText(userUrl), + }, + ]; + return (
{ window.location.href = `/users/${user.id}`; }} + onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }} >
M @@ -789,6 +915,14 @@ function UserCard({ user }: { user: User }) { {user.email}
+ {ctxMenu && ( + setCtxMenu(null)} + /> + )} ); } diff --git a/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico b/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico differ diff --git a/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz new file mode 100644 index 0000000..566997d Binary files /dev/null and b/priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz differ diff --git a/priv/static/images/logo.svg.gz b/priv/static/images/logo.svg.gz new file mode 100644 index 0000000..566997d Binary files /dev/null and b/priv/static/images/logo.svg.gz differ diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz new file mode 100644 index 0000000..18b919c Binary files /dev/null and b/priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz differ diff --git a/priv/static/robots.txt.gz b/priv/static/robots.txt.gz new file mode 100644 index 0000000..18b919c Binary files /dev/null and b/priv/static/robots.txt.gz differ