mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 02:15:01 +00:00
Action buttons
This commit is contained in:
parent
52ff84d29b
commit
0b2ff44b1c
7 changed files with 295 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal file
233
packages/skin-database/app/(modern)/scroll/SkinActionIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export type ClientSkin = {
|
|||
fileName: string;
|
||||
md5: string;
|
||||
readmeStart: string;
|
||||
downloadUrl: string;
|
||||
shareUrl: string;
|
||||
nsfw: boolean;
|
||||
likeCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
12
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue