added right click context menu and did a static deployment test
This commit is contained in:
@@ -734,3 +734,47 @@ html, body {
|
|||||||
border-radius: var(--mx-radius-sm);
|
border-radius: var(--mx-radius-sm);
|
||||||
display: block;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,75 @@ function getAssetHost(): string {
|
|||||||
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
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<HTMLDivElement>(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(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="mx-context-menu"
|
||||||
|
style={{ left, top }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.type === "separator" ? (
|
||||||
|
<div key={i} className="mx-context-menu-separator" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="mx-context-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Components ─────────────────────────────────────────────────────────────────
|
// ── Components ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -319,8 +388,43 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
const [editText, setEditText] = useState(tweet.content);
|
const [editText, setEditText] = useState(tweet.content);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const qc = useQueryClient();
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await destroyTweet({
|
const res = await destroyTweet({
|
||||||
@@ -379,6 +483,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
className="mx-tweet"
|
className="mx-tweet"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
>
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<div className="mx-tweet-avatar">
|
||||||
<span>M</span>
|
<span>M</span>
|
||||||
@@ -492,6 +597,14 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
|
|
||||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
items={ctxItems}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -775,11 +888,24 @@ function RefreshButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
className="mx-tweet"
|
className="mx-tweet"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
>
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<div className="mx-tweet-avatar">
|
||||||
<span>M</span>
|
<span>M</span>
|
||||||
@@ -789,6 +915,14 @@ function UserCard({ user }: { user: User }) {
|
|||||||
<span className="mx-tweet-handle">{user.email}</span>
|
<span className="mx-tweet-handle">{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
items={ctxItems}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico
Normal file
BIN
priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
|
||||||
|
fill="#FD4F00"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
BIN
priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz
Normal file
BIN
priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz
Normal file
Binary file not shown.
BIN
priv/static/images/logo.svg.gz
Normal file
BIN
priv/static/images/logo.svg.gz
Normal file
Binary file not shown.
5
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt
Normal file
5
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt
Normal file
@@ -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: /
|
||||||
BIN
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz
Normal file
BIN
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz
Normal file
Binary file not shown.
BIN
priv/static/robots.txt.gz
Normal file
BIN
priv/static/robots.txt.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user