Action buttons

This commit is contained in:
Jordan Eldredge 2025-11-07 15:44:54 -08:00
parent 52ff84d29b
commit 0b2ff44b1c
7 changed files with 295 additions and 12 deletions

View file

@ -1,6 +1,8 @@
"use server";
import { knex } from "../../../db";
import { markAsNSFW } from "../../../data/skins";
import UserContext from "../../../data/UserContext";
export async function logUserEvent(sessionId: string, event: UserEvent) {
const timestamp = Date.now();
@ -10,6 +12,13 @@ export async function logUserEvent(sessionId: string, event: UserEvent) {
timestamp: timestamp,
metadata: JSON.stringify(event),
});
// If this is a NSFW report, call the existing infrastructure
if (event.type === "skin_flag_nsfw") {
// Create an anonymous user context for the report
const ctx = new UserContext();
await markAsNSFW(ctx, event.skinMd5);
}
}
type UserEvent =
@ -50,6 +59,15 @@ type UserEvent =
type: "skin_download";
skinMd5: string;
}
| {
type: "skin_like";
skinMd5: string;
liked: boolean;
}
| {
type: "skin_flag_nsfw";
skinMd5: string;
}
| {
type: "share_open";
skinMd5: string;

View file

@ -0,0 +1,233 @@
"use client";
import { useState, ReactNode } from "react";
import { Heart, Share2, Flag, Download } from "lucide-react";
import { ClientSkin } from "./SkinScroller";
import { logUserEvent } from "./Events";
type Props = {
skin: ClientSkin;
sessionId: string;
};
export default function SkinActionIcons({ skin, sessionId }: Props) {
return (
<div
style={{
position: "absolute",
right: "1rem",
bottom: "2rem",
display: "flex",
flexDirection: "column",
gap: "1.5rem",
paddingBottom: "1rem",
}}
>
<LikeButton skin={skin} sessionId={sessionId} />
<ShareButton skin={skin} sessionId={sessionId} />
<FlagButton skin={skin} sessionId={sessionId} />
<DownloadButton skin={skin} sessionId={sessionId} />
</div>
);
}
// Implementation details below
type ButtonProps = {
onClick: () => void;
disabled?: boolean;
opacity?: number;
"aria-label": string;
children: ReactNode;
};
function Button({
onClick,
disabled = false,
opacity = 1,
"aria-label": ariaLabel,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
background: "none",
border: "none",
cursor: disabled ? "default" : "pointer",
padding: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.25rem",
opacity,
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
}}
aria-label={ariaLabel}
>
{children}
</button>
);
}
type LikeButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function LikeButton({ skin, sessionId }: LikeButtonProps) {
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(skin.likeCount);
const handleLike = async () => {
const newLikedState = !isLiked;
setIsLiked(newLikedState);
// Optimistically update the like count
setLikeCount((prevCount) =>
newLikedState ? prevCount + 1 : prevCount - 1
);
logUserEvent(sessionId, {
type: "skin_like",
skinMd5: skin.md5,
liked: newLikedState,
});
};
return (
<Button onClick={handleLike} aria-label="Like">
<Heart
size={32}
color="white"
fill={isLiked ? "white" : "none"}
strokeWidth={2}
/>
{likeCount > 0 && (
<span
style={{
color: "white",
fontSize: "0.75rem",
fontWeight: "bold",
}}
>
{likeCount}
</span>
)}
</Button>
);
}
type ShareButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function ShareButton({ skin, sessionId }: ShareButtonProps) {
const handleShare = async () => {
if (navigator.share) {
try {
logUserEvent(sessionId, {
type: "share_open",
skinMd5: skin.md5,
});
await navigator.share({
title: skin.fileName,
text: `Check out this Winamp skin: ${skin.fileName}`,
url: skin.shareUrl,
});
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
} catch (error) {
// User cancelled or share failed
if (error instanceof Error && error.name !== "AbortError") {
console.error("Share failed:", error);
logUserEvent(sessionId, {
type: "share_failure",
skinMd5: skin.md5,
errorMessage: error.message,
});
}
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(skin.shareUrl);
logUserEvent(sessionId, {
type: "share_success",
skinMd5: skin.md5,
});
alert("Share link copied to clipboard!");
}
};
return (
<Button onClick={handleShare} aria-label="Share">
<Share2 size={32} color="white" strokeWidth={2} />
</Button>
);
}
type FlagButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function FlagButton({ skin, sessionId }: FlagButtonProps) {
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
const handleFlagNsfw = async () => {
if (isFlagged) return; // Only allow flagging once
setIsFlagged(true);
logUserEvent(sessionId, {
type: "skin_flag_nsfw",
skinMd5: skin.md5,
});
};
return (
<Button
onClick={handleFlagNsfw}
disabled={isFlagged}
opacity={isFlagged ? 0.5 : 1}
aria-label="Flag as NSFW"
>
<Flag
size={32}
color="white"
fill={isFlagged ? "white" : "none"}
strokeWidth={2}
/>
</Button>
);
}
type DownloadButtonProps = {
skin: ClientSkin;
sessionId: string;
};
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
const handleDownload = async () => {
logUserEvent(sessionId, {
type: "skin_download",
skinMd5: skin.md5,
});
// Trigger download
window.location.href = skin.downloadUrl;
};
return (
<Button onClick={handleDownload} aria-label="Download">
<Download size={32} color="white" strokeWidth={2} />
</Button>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { ClientSkin } from "./SkinScroller";
import SkinActionIcons from "./SkinActionIcons";
type Props = {
skin: ClientSkin;
@ -8,7 +9,7 @@ type Props = {
sessionId: string;
};
export default function SkinPage({ skin, index }: Props) {
export default function SkinPage({ skin, index, sessionId }: Props) {
return (
<div
key={skin.md5}
@ -22,18 +23,24 @@ export default function SkinPage({ skin, index }: Props) {
height: "100vh",
scrollSnapAlign: "start",
scrollSnapStop: "always",
position: "relative",
}}
>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
paddingTop: "4rem",
boxSizing: "border-box",
width: "100%",
imageRendering: "pixelated",
}}
/>
<div style={{ position: "relative" }}>
<img
src={skin.screenshotUrl}
alt={skin.fileName}
style={{
paddingTop: "4rem",
boxSizing: "border-box",
width: "100%",
imageRendering: "pixelated",
}}
/>
<SkinActionIcons skin={skin} sessionId={sessionId} />
</div>
<div
style={{
color: "white",

View file

@ -9,6 +9,10 @@ export type ClientSkin = {
fileName: string;
md5: string;
readmeStart: string;
downloadUrl: string;
shareUrl: string;
nsfw: boolean;
likeCount: number;
};
type Props = {

View file

@ -15,12 +15,20 @@ async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
page.map(async (item) => {
const model = await SkinModel.fromMd5Assert(ctx, item.md5);
const readmeText = await model.getReadme();
const fileName = await model.getFileName();
const tweet = await model.getTweet();
const likeCount = tweet ? tweet.getLikes() : 0;
return {
screenshotUrl: model.getScreenshotUrl(),
md5: item.md5,
// TODO: Normalize to .wsz
fileName: await model.getFileName(),
fileName: fileName,
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
downloadUrl: model.getSkinUrl(),
shareUrl: `https://skins.webamp.org/skin/${item.md5}`,
nsfw: await model.getIsNsfw(),
likeCount: likeCount,
};
})
);

View file

@ -25,6 +25,7 @@
"jszip": "^3.10.1",
"knex": "^0.21.1",
"lru-cache": "^6.0.0",
"lucide-react": "^0.553.0",
"mastodon-api": "^1.3.0",
"md5": "^2.2.1",
"next": "^15.3.3",

12
pnpm-lock.yaml generated
View file

@ -187,6 +187,9 @@ importers:
lru-cache:
specifier: ^6.0.0
version: 6.0.0
lucide-react:
specifier: ^0.553.0
version: 0.553.0(react@19.1.0)
mastodon-api:
specifier: ^1.3.0
version: 1.3.0
@ -8944,6 +8947,11 @@ packages:
lru_map@0.3.3:
resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==}
lucide-react@0.553.0:
resolution: {integrity: sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@ -23887,6 +23895,10 @@ snapshots:
lru_map@0.3.3: {}
lucide-react@0.553.0(react@19.1.0):
dependencies:
react: 19.1.0
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8