diff --git a/packages/skin-database/app/(modern)/scroll/Events.ts b/packages/skin-database/app/(modern)/scroll/Events.ts
index ef917ee1..ce87d360 100644
--- a/packages/skin-database/app/(modern)/scroll/Events.ts
+++ b/packages/skin-database/app/(modern)/scroll/Events.ts
@@ -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;
diff --git a/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx b/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
new file mode 100644
index 00000000..be57407e
--- /dev/null
+++ b/packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
+
+// 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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx
index f567c280..18614bfe 100644
--- a/packages/skin-database/app/(modern)/scroll/SkinPage.tsx
+++ b/packages/skin-database/app/(modern)/scroll/SkinPage.tsx
@@ -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 (
-

+
+

+
+
+
+
{
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,
};
})
);
diff --git a/packages/skin-database/package.json b/packages/skin-database/package.json
index 7bdf7805..18a354da 100644
--- a/packages/skin-database/package.json
+++ b/packages/skin-database/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c91c9e80..5712bd2a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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