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 (
- {skin.fileName} +
+ {skin.fileName} + + +
+
{ 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