diff --git a/.env.example b/.env.example index d3784fb..814754c 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ UPLOAD_DIR="./uploads" # S2VID_EDITION="selfhosted" # Admin API key for approving/rejecting quota extension requests (Bearer token) # ADMIN_API_KEY="your-secret-admin-key" +# Admin UI: /admin (footer badges) — unlock with the same Bearer key +# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY +# Admin badges: GET/POST /api/admin/badges, PATCH/DELETE /api/admin/badges/:id +# Public badges: GET /api/badges # Pro users generate API keys in Dashboard → Settings (stored hashed; shown once) # Optional: override bundled ffmpeg-static binary (leave unset to use npm package) # FFMPEG_PATH="C:/path/to/ffmpeg.exe" @@ -44,3 +48,4 @@ NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid" # Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it # Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" } # Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY +# See also /admin for footer badge management diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..cbea81a --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,26 @@ +import { AdminBadgePanel } from "@/components/AdminBadgePanel"; + +export const metadata = { + title: "Admin · Footer badges · Songs2VID", + robots: { index: false, follow: false }, +}; + +export default function AdminBadgesPage() { + return ( +
+
+
+

+ Admin +

+

Footer badges

+

+ Manage the infinite badge marquee on the public site footer. Only active badges are + shown. +

+
+ +
+
+ ); +} diff --git a/app/api/admin/badges/[id]/route.ts b/app/api/admin/badges/[id]/route.ts new file mode 100644 index 0000000..e2d0f2d --- /dev/null +++ b/app/api/admin/badges/[id]/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { prisma } from "@/lib/db"; +import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const denied = requireAdmin(req); + if (denied) return denied; + + const { id } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const existing = await prisma.footerBadge.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + } + + const input = (body ?? {}) as Record; + const patch: { + name?: string; + embedHtml?: string | null; + imageUrl?: string | null; + linkUrl?: string | null; + isActive?: boolean; + sortOrder?: number; + } = {}; + + if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name; + if ("embedHtml" in input) { + patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null; + } + if ("imageUrl" in input) { + patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null; + } + if ("linkUrl" in input) { + patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null; + } + if ("isActive" in input && typeof input.isActive === "boolean") { + patch.isActive = input.isActive; + } + if ("sortOrder" in input && typeof input.sortOrder === "number") { + patch.sortOrder = input.sortOrder; + } + + const fields = normalizeBadgeFields({ + name: patch.name ?? existing.name, + embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml, + imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl, + linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl, + isActive: patch.isActive ?? existing.isActive, + sortOrder: patch.sortOrder ?? existing.sortOrder, + }); + + // Toggle-only updates should not re-validate empty content + const contentChanging = + "name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input; + if (contentChanging) { + const error = validateBadgeFields(fields); + if (error) return NextResponse.json({ error }, { status: 400 }); + } + + const badge = await prisma.footerBadge.update({ + where: { id }, + data: contentChanging + ? fields + : { + ...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}), + ...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}), + }, + }); + + return NextResponse.json({ badge }); +} + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const denied = requireAdmin(req); + if (denied) return denied; + + const { id } = await ctx.params; + const existing = await prisma.footerBadge.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Badge not found" }, { status: 404 }); + } + + await prisma.footerBadge.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/badges/route.ts b/app/api/admin/badges/route.ts new file mode 100644 index 0000000..8a7421d --- /dev/null +++ b/app/api/admin/badges/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { prisma } from "@/lib/db"; +import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; + +export async function GET(req: NextRequest) { + const denied = requireAdmin(req); + if (denied) return denied; + + const badges = await prisma.footerBadge.findMany({ + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + }); + + return NextResponse.json({ badges }); +} + +export async function POST(req: NextRequest) { + const denied = requireAdmin(req); + if (denied) return denied; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const input = (body ?? {}) as Record; + const fields = normalizeBadgeFields({ + name: typeof input.name === "string" ? input.name : "", + embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null, + imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null, + linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null, + isActive: typeof input.isActive === "boolean" ? input.isActive : true, + sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined, + }); + + const error = validateBadgeFields(fields); + if (error) return NextResponse.json({ error }, { status: 400 }); + + if (fields.sortOrder === 0) { + const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } }); + fields.sortOrder = (max._max.sortOrder ?? -1) + 1; + } + + const badge = await prisma.footerBadge.create({ data: fields }); + return NextResponse.json({ badge }, { status: 201 }); +} diff --git a/app/api/badges/route.ts b/app/api/badges/route.ts new file mode 100644 index 0000000..7f11607 --- /dev/null +++ b/app/api/badges/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +/** Public: active footer badges for the landing marquee. */ +export async function GET() { + const badges = await prisma.footerBadge.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + id: true, + name: true, + embedHtml: true, + imageUrl: true, + linkUrl: true, + sortOrder: true, + }, + }); + + return NextResponse.json({ badges }, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + }, + }); +} diff --git a/app/globals.css b/app/globals.css index cc93f9a..dcce869 100644 --- a/app/globals.css +++ b/app/globals.css @@ -116,6 +116,68 @@ .mobile-sidebar-link-danger:hover::before { @apply bg-red-400; } + + .badge-marquee { + @apply relative w-full overflow-hidden; + mask-image: linear-gradient( + to right, + transparent, + black 8%, + black 92%, + transparent + ); + } + + .badge-marquee-track { + @apply flex w-max items-center gap-10; + animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite; + } + + .badge-marquee:hover .badge-marquee-track { + animation-play-state: paused; + } + + @media (prefers-reduced-motion: reduce) { + .badge-marquee-track { + animation: none; + flex-wrap: wrap; + justify-content: center; + width: 100%; + } + + .badge-marquee-item[aria-hidden="true"] { + display: none; + } + } + + .badge-marquee-item { + @apply flex shrink-0 items-center justify-center; + } + + .badge-marquee-slot { + @apply flex h-14 max-h-14 items-center justify-center; + } + + .badge-marquee-slot a, + .badge-marquee-link { + @apply m-0 inline-flex items-center p-0 leading-none no-underline; + } + + .badge-marquee-slot img, + .badge-marquee-slot iframe, + .badge-marquee-slot svg, + .badge-marquee-slot object { + display: block !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + max-height: 3.5rem !important; + width: auto !important; + height: auto !important; + max-width: min(280px, 70vw) !important; + object-fit: contain; + vertical-align: middle; + } } @keyframes mockup-float { @@ -246,3 +308,12 @@ transform: scale(0.6) rotate(90deg); } } + +@keyframes badge-marquee-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 6ab47a5..0ddae53 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { Providers } from "./providers"; import { JsonLd } from "@/components/JsonLd"; +import { Matomo } from "@/components/Matomo"; const inter = Inter({ subsets: ["latin"] }); @@ -84,6 +85,7 @@ export default function RootLayout({ + {children} diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 6aff5ad..4ce67dd 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -82,6 +82,11 @@ export default function PrivacyPage() {
  • IP address, browser type, device information, and request logs
  • Error reports and operational diagnostics
  • +
  • + Website and product analytics events collected via Matomo (self-hosted at + analytics.atakanozban.com) to understand traffic and improve the Service — see section + 10 +
  • 3.4 Payment data

    @@ -146,6 +151,11 @@ export default function PrivacyPage() { ) +
  • + Matomo (self-hosted analytics): privacy-friendly analytics we operate at + analytics.atakanozban.com to measure visits and improve Songs2VID. Analytics data stays on + infrastructure we control; we do not sell it to advertising networks. +
  • These providers process data only as necessary to deliver their services and under @@ -244,11 +254,26 @@ export default function PrivacyPage() { 6.

    -

    10. Cookies and local storage

    +

    10. Cookies, analytics, and local storage

    We use essential cookies and similar technologies for authentication, session management, - and security. We do not use non-essential tracking cookies unless disclosed separately and - enabled with your consent where required. + and security. +

    +

    + We also use Matomo, a self-hosted analytics tool on{" "} + analytics.atakanozban.com, on our website (including the landing page and + dashboard) and documentation site. Matomo helps us understand how people use Songs2VID so we + can improve functionality, reliability, and content. Typical data includes pages viewed, + approximate location derived from IP, device/browser type, referring site, and interaction + events. We configure Matomo for our domains under *.songs2vid.com. +

    +

    + We collect this analytics data to improve the Service, not to sell personal profiles to + advertisers. The legal basis is our legitimate interest in operating and improving + Songs2VID (and consent where required by local law). You can block analytics with browser + settings, extensions, or Do Not Track / equivalent controls where supported. For rights + requests related to analytics data, contact{" "} + {LEGAL_OPERATOR.email}.

    11. Security

    diff --git a/app/robots.ts b/app/robots.ts index 332d58c..fd6a184 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -5,6 +5,7 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: "*", allow: "/", + disallow: ["/admin", "/api/admin"], }, sitemap: "https://songs2vid.com/sitemap.xml", }; diff --git a/components/AdminBadgePanel.tsx b/components/AdminBadgePanel.tsx new file mode 100644 index 0000000..4cfbbda --- /dev/null +++ b/components/AdminBadgePanel.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +const STORAGE_KEY = "s2vid_admin_api_key"; + +export type AdminBadge = { + id: string; + name: string; + embedHtml: string | null; + imageUrl: string | null; + linkUrl: string | null; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; + +function authHeaders(key: string): HeadersInit { + return { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }; +} + +export function AdminBadgePanel() { + const [apiKey, setApiKey] = useState(""); + const [unlocked, setUnlocked] = useState(false); + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [name, setName] = useState(""); + const [embedHtml, setEmbedHtml] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) { + setApiKey(stored); + setUnlocked(true); + } + }, []); + + const loadBadges = useCallback(async (key: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/badges", { + headers: authHeaders(key), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load badges"); + setBadges(data.badges); + setUnlocked(true); + sessionStorage.setItem(STORAGE_KEY, key); + } catch (err) { + setUnlocked(false); + setBadges([]); + setError(err instanceof Error ? err.message : "Failed to load badges"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (unlocked && apiKey) { + void loadBadges(apiKey); + } + }, [unlocked, apiKey, loadBadges]); + + async function unlock(e: FormEvent) { + e.preventDefault(); + const key = apiKey.trim(); + if (!key) { + setError("Enter ADMIN_API_KEY"); + return; + } + await loadBadges(key); + } + + function lock() { + sessionStorage.removeItem(STORAGE_KEY); + setApiKey(""); + setUnlocked(false); + setBadges([]); + setSuccess(null); + setError(null); + } + + async function createBadge(e: FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + setSuccess(null); + try { + const res = await fetch("/api/admin/badges", { + method: "POST", + headers: authHeaders(apiKey), + body: JSON.stringify({ + name: name.trim(), + embedHtml: embedHtml.trim() || null, + imageUrl: imageUrl.trim() || null, + linkUrl: linkUrl.trim() || null, + isActive: true, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to create badge"); + setName(""); + setEmbedHtml(""); + setImageUrl(""); + setLinkUrl(""); + setSuccess(`Added “${data.badge.name}”`); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create badge"); + } finally { + setSaving(false); + } + } + + async function toggleActive(badge: AdminBadge) { + setError(null); + setSuccess(null); + try { + const res = await fetch(`/api/admin/badges/${badge.id}`, { + method: "PATCH", + headers: authHeaders(apiKey), + body: JSON.stringify({ isActive: !badge.isActive }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to update badge"); + setSuccess( + data.badge.isActive + ? `“${data.badge.name}” is now active` + : `“${data.badge.name}” is now inactive`, + ); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update badge"); + } + } + + async function removeBadge(badge: AdminBadge) { + if (!confirm(`Delete badge “${badge.name}”?`)) return; + setError(null); + setSuccess(null); + try { + const res = await fetch(`/api/admin/badges/${badge.id}`, { + method: "DELETE", + headers: authHeaders(apiKey), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to delete badge"); + setSuccess(`Deleted “${badge.name}”`); + await loadBadges(apiKey); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete badge"); + } + } + + if (!unlocked) { + return ( +
    +

    Admin unlock

    +

    + Enter ADMIN_API_KEY to manage footer badges. + The key stays in this browser tab only (sessionStorage). +

    +
    + setApiKey(e.target.value)} + placeholder="ADMIN_API_KEY" + className="input-field flex-1" + autoComplete="off" + /> + +
    + {error ?

    {error}

    : null} +
    + ); + } + + return ( +
    +
    +

    + Authenticated with admin API key. Changes apply to the live footer marquee. +

    + +
    + + {error ?

    {error}

    : null} + {success ?

    {success}

    : null} + +
    +

    Add badge

    +

    + Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs. +

    +
    +
    + + setName(e.target.value)} + className="input-field" + placeholder="Product Hunt" + required + /> +
    +
    + +