Skip to content

Commit 0b2ff44

Browse files
committed
Action buttons
1 parent 52ff84d commit 0b2ff44

File tree

7 files changed

+295
-12
lines changed

7 files changed

+295
-12
lines changed

packages/skin-database/app/(modern)/scroll/Events.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use server";
22

33
import { knex } from "../../../db";
4+
import { markAsNSFW } from "../../../data/skins";
5+
import UserContext from "../../../data/UserContext";
46

57
export async function logUserEvent(sessionId: string, event: UserEvent) {
68
const timestamp = Date.now();
@@ -10,6 +12,13 @@ export async function logUserEvent(sessionId: string, event: UserEvent) {
1012
timestamp: timestamp,
1113
metadata: JSON.stringify(event),
1214
});
15+
16+
// If this is a NSFW report, call the existing infrastructure
17+
if (event.type === "skin_flag_nsfw") {
18+
// Create an anonymous user context for the report
19+
const ctx = new UserContext();
20+
await markAsNSFW(ctx, event.skinMd5);
21+
}
1322
}
1423

1524
type UserEvent =
@@ -50,6 +59,15 @@ type UserEvent =
5059
type: "skin_download";
5160
skinMd5: string;
5261
}
62+
| {
63+
type: "skin_like";
64+
skinMd5: string;
65+
liked: boolean;
66+
}
67+
| {
68+
type: "skin_flag_nsfw";
69+
skinMd5: string;
70+
}
5371
| {
5472
type: "share_open";
5573
skinMd5: string;
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"use client";
2+
3+
import { useState, ReactNode } from "react";
4+
import { Heart, Share2, Flag, Download } from "lucide-react";
5+
import { ClientSkin } from "./SkinScroller";
6+
import { logUserEvent } from "./Events";
7+
8+
type Props = {
9+
skin: ClientSkin;
10+
sessionId: string;
11+
};
12+
13+
export default function SkinActionIcons({ skin, sessionId }: Props) {
14+
return (
15+
<div
16+
style={{
17+
position: "absolute",
18+
right: "1rem",
19+
bottom: "2rem",
20+
display: "flex",
21+
flexDirection: "column",
22+
gap: "1.5rem",
23+
paddingBottom: "1rem",
24+
}}
25+
>
26+
<LikeButton skin={skin} sessionId={sessionId} />
27+
<ShareButton skin={skin} sessionId={sessionId} />
28+
<FlagButton skin={skin} sessionId={sessionId} />
29+
<DownloadButton skin={skin} sessionId={sessionId} />
30+
</div>
31+
);
32+
}
33+
34+
// Implementation details below
35+
36+
type ButtonProps = {
37+
onClick: () => void;
38+
disabled?: boolean;
39+
opacity?: number;
40+
"aria-label": string;
41+
children: ReactNode;
42+
};
43+
44+
function Button({
45+
onClick,
46+
disabled = false,
47+
opacity = 1,
48+
"aria-label": ariaLabel,
49+
children,
50+
}: ButtonProps) {
51+
return (
52+
<button
53+
onClick={onClick}
54+
disabled={disabled}
55+
style={{
56+
background: "none",
57+
border: "none",
58+
cursor: disabled ? "default" : "pointer",
59+
padding: 0,
60+
display: "flex",
61+
flexDirection: "column",
62+
alignItems: "center",
63+
gap: "0.25rem",
64+
opacity,
65+
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))",
66+
}}
67+
aria-label={ariaLabel}
68+
>
69+
{children}
70+
</button>
71+
);
72+
}
73+
74+
type LikeButtonProps = {
75+
skin: ClientSkin;
76+
sessionId: string;
77+
};
78+
79+
function LikeButton({ skin, sessionId }: LikeButtonProps) {
80+
const [isLiked, setIsLiked] = useState(false);
81+
const [likeCount, setLikeCount] = useState(skin.likeCount);
82+
83+
const handleLike = async () => {
84+
const newLikedState = !isLiked;
85+
setIsLiked(newLikedState);
86+
87+
// Optimistically update the like count
88+
setLikeCount((prevCount) =>
89+
newLikedState ? prevCount + 1 : prevCount - 1
90+
);
91+
92+
logUserEvent(sessionId, {
93+
type: "skin_like",
94+
skinMd5: skin.md5,
95+
liked: newLikedState,
96+
});
97+
};
98+
99+
return (
100+
<Button onClick={handleLike} aria-label="Like">
101+
<Heart
102+
size={32}
103+
color="white"
104+
fill={isLiked ? "white" : "none"}
105+
strokeWidth={2}
106+
/>
107+
{likeCount > 0 && (
108+
<span
109+
style={{
110+
color: "white",
111+
fontSize: "0.75rem",
112+
fontWeight: "bold",
113+
}}
114+
>
115+
{likeCount}
116+
</span>
117+
)}
118+
</Button>
119+
);
120+
}
121+
122+
type ShareButtonProps = {
123+
skin: ClientSkin;
124+
sessionId: string;
125+
};
126+
127+
function ShareButton({ skin, sessionId }: ShareButtonProps) {
128+
const handleShare = async () => {
129+
if (navigator.share) {
130+
try {
131+
logUserEvent(sessionId, {
132+
type: "share_open",
133+
skinMd5: skin.md5,
134+
});
135+
136+
await navigator.share({
137+
title: skin.fileName,
138+
text: `Check out this Winamp skin: ${skin.fileName}`,
139+
url: skin.shareUrl,
140+
});
141+
142+
logUserEvent(sessionId, {
143+
type: "share_success",
144+
skinMd5: skin.md5,
145+
});
146+
} catch (error) {
147+
// User cancelled or share failed
148+
if (error instanceof Error && error.name !== "AbortError") {
149+
console.error("Share failed:", error);
150+
logUserEvent(sessionId, {
151+
type: "share_failure",
152+
skinMd5: skin.md5,
153+
errorMessage: error.message,
154+
});
155+
}
156+
}
157+
} else {
158+
// Fallback: copy to clipboard
159+
await navigator.clipboard.writeText(skin.shareUrl);
160+
161+
logUserEvent(sessionId, {
162+
type: "share_success",
163+
skinMd5: skin.md5,
164+
});
165+
alert("Share link copied to clipboard!");
166+
}
167+
};
168+
169+
return (
170+
<Button onClick={handleShare} aria-label="Share">
171+
<Share2 size={32} color="white" strokeWidth={2} />
172+
</Button>
173+
);
174+
}
175+
176+
type FlagButtonProps = {
177+
skin: ClientSkin;
178+
sessionId: string;
179+
};
180+
181+
function FlagButton({ skin, sessionId }: FlagButtonProps) {
182+
const [isFlagged, setIsFlagged] = useState(skin.nsfw);
183+
184+
const handleFlagNsfw = async () => {
185+
if (isFlagged) return; // Only allow flagging once
186+
187+
setIsFlagged(true);
188+
189+
logUserEvent(sessionId, {
190+
type: "skin_flag_nsfw",
191+
skinMd5: skin.md5,
192+
});
193+
};
194+
195+
return (
196+
<Button
197+
onClick={handleFlagNsfw}
198+
disabled={isFlagged}
199+
opacity={isFlagged ? 0.5 : 1}
200+
aria-label="Flag as NSFW"
201+
>
202+
<Flag
203+
size={32}
204+
color="white"
205+
fill={isFlagged ? "white" : "none"}
206+
strokeWidth={2}
207+
/>
208+
</Button>
209+
);
210+
}
211+
212+
type DownloadButtonProps = {
213+
skin: ClientSkin;
214+
sessionId: string;
215+
};
216+
217+
function DownloadButton({ skin, sessionId }: DownloadButtonProps) {
218+
const handleDownload = async () => {
219+
logUserEvent(sessionId, {
220+
type: "skin_download",
221+
skinMd5: skin.md5,
222+
});
223+
224+
// Trigger download
225+
window.location.href = skin.downloadUrl;
226+
};
227+
228+
return (
229+
<Button onClick={handleDownload} aria-label="Download">
230+
<Download size={32} color="white" strokeWidth={2} />
231+
</Button>
232+
);
233+
}

packages/skin-database/app/(modern)/scroll/SkinPage.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"use client";
22

33
import { ClientSkin } from "./SkinScroller";
4+
import SkinActionIcons from "./SkinActionIcons";
45

56
type Props = {
67
skin: ClientSkin;
78
index: number;
89
sessionId: string;
910
};
1011

11-
export default function SkinPage({ skin, index }: Props) {
12+
export default function SkinPage({ skin, index, sessionId }: Props) {
1213
return (
1314
<div
1415
key={skin.md5}
@@ -22,18 +23,24 @@ export default function SkinPage({ skin, index }: Props) {
2223
height: "100vh",
2324
scrollSnapAlign: "start",
2425
scrollSnapStop: "always",
26+
position: "relative",
2527
}}
2628
>
27-
<img
28-
src={skin.screenshotUrl}
29-
alt={skin.fileName}
30-
style={{
31-
paddingTop: "4rem",
32-
boxSizing: "border-box",
33-
width: "100%",
34-
imageRendering: "pixelated",
35-
}}
36-
/>
29+
<div style={{ position: "relative" }}>
30+
<img
31+
src={skin.screenshotUrl}
32+
alt={skin.fileName}
33+
style={{
34+
paddingTop: "4rem",
35+
boxSizing: "border-box",
36+
width: "100%",
37+
imageRendering: "pixelated",
38+
}}
39+
/>
40+
41+
<SkinActionIcons skin={skin} sessionId={sessionId} />
42+
</div>
43+
3744
<div
3845
style={{
3946
color: "white",

packages/skin-database/app/(modern)/scroll/SkinScroller.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export type ClientSkin = {
99
fileName: string;
1010
md5: string;
1111
readmeStart: string;
12+
downloadUrl: string;
13+
shareUrl: string;
14+
nsfw: boolean;
15+
likeCount: number;
1216
};
1317

1418
type Props = {

packages/skin-database/app/(modern)/scroll/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,20 @@ async function getClientSkins(sessionId: string): Promise<ClientSkin[]> {
1515
page.map(async (item) => {
1616
const model = await SkinModel.fromMd5Assert(ctx, item.md5);
1717
const readmeText = await model.getReadme();
18+
const fileName = await model.getFileName();
19+
const tweet = await model.getTweet();
20+
const likeCount = tweet ? tweet.getLikes() : 0;
21+
1822
return {
1923
screenshotUrl: model.getScreenshotUrl(),
2024
md5: item.md5,
2125
// TODO: Normalize to .wsz
22-
fileName: await model.getFileName(),
26+
fileName: fileName,
2327
readmeStart: readmeText ? readmeText.slice(0, 200) : "",
28+
downloadUrl: model.getSkinUrl(),
29+
shareUrl: `https://skins.webamp.org/skin/${item.md5}`,
30+
nsfw: await model.getIsNsfw(),
31+
likeCount: likeCount,
2432
};
2533
})
2634
);

packages/skin-database/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"jszip": "^3.10.1",
2626
"knex": "^0.21.1",
2727
"lru-cache": "^6.0.0",
28+
"lucide-react": "^0.553.0",
2829
"mastodon-api": "^1.3.0",
2930
"md5": "^2.2.1",
3031
"next": "^15.3.3",

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)