diff --git a/api/cors.js b/api/cors.js new file mode 100644 index 0000000..995f3ab --- /dev/null +++ b/api/cors.js @@ -0,0 +1,32 @@ +export const allowedOrigins = new Set([ + 'http://localhost:5173', + 'http://localhost:8080', + 'http://localhost:3000', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:8080', + 'http://127.0.0.1:3000', + 'https://renderdragon.org', + 'https://www.renderdragon.org', + 'https://assets-api-worker.powernplant101-c6b.workers.dev', +]); + +const isLocalNetworkHostname = (hostname) => { + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') return true; + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false; + const [first, second] = octets; + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 100 && second >= 64 && second <= 127); +}; + +export const isAllowedOrigin = (origin) => { + if (allowedOrigins.has(origin)) return true; + try { + const url = new URL(origin); + return url.protocol === 'http:' && isLocalNetworkHostname(url.hostname); + } catch { + return false; + } +}; diff --git a/api/looney-check.js b/api/looney-check.js new file mode 100644 index 0000000..70ff940 --- /dev/null +++ b/api/looney-check.js @@ -0,0 +1,311 @@ +import crypto from 'node:crypto'; +import { createClient } from '@supabase/supabase-js'; +import { isAllowedOrigin } from './cors.js'; + +const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; +const DEFAULT_LOONEY_URL = 'https://looney.codersoft.xyz/check'; +const DAILY_CHECK_LIMIT = 5; + +function getHeader(request, name) { + if (request.headers?.get) return request.headers.get(name); + return request.headers?.[name.toLowerCase()] || request.headers?.[name] || null; +} + +function getClientIp(request) { + // Only use the platform-provided value; forwarded headers can be forged by callers. + const platformIp = getHeader(request, 'x-vercel-ip'); + return platformIp ? String(platformIp).trim() : null; +} + +function hashIdentifier(value) { + return crypto.createHash('sha256').update(`${process.env.LOONEY_RATE_LIMIT_SALT || 'looney-rate-limit'}:${value}`).digest('hex'); +} + +function getSupabaseAdmin() { + const url = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) throw new Error('Supabase rate-limit storage is not configured'); + return createClient(url, key, { auth: { autoRefreshToken: false, persistSession: false } }); +} + +async function getAuthenticatedUserId(request) { + const authorization = getHeader(request, 'authorization') || ''; + if (!authorization.startsWith('Bearer ')) return null; + try { + const { data } = await getSupabaseAdmin().auth.getUser(authorization.slice(7)); + return data.user?.id || null; + } catch { + return null; + } +} + +async function consumeRateLimit(request) { + const browserId = getHeader(request, 'x-looney-browser-id'); + if (browserId && String(browserId).length > 200) return { allowed: false, error: 'The browser identifier is invalid.' }; + const userId = await getAuthenticatedUserId(request); + const clientIp = getClientIp(request); + const buckets = []; + if (browserId) buckets.push({ type: 'browser', hash: hashIdentifier(`browser:${browserId}`) }); + if (userId) { + buckets.push({ type: 'ip', hash: hashIdentifier(`ip:${clientIp || 'unknown'}`) }); + buckets.push({ type: 'account', hash: hashIdentifier(`account:${userId}`), user_id: userId }); + } else { + if (!clientIp) return { allowed: false, error: 'Unable to verify the anonymous request identity.' }; + buckets.push({ type: 'ip', hash: hashIdentifier(`ip:${clientIp}`) }); + } + const { data, error } = await getSupabaseAdmin().rpc('consume_looney_check_rate_limit', { p_buckets: buckets, p_limit: DAILY_CHECK_LIMIT, p_consume: true }); + if (error) { + // Keep local development usable before the migration is pushed; production fails closed. + if (error.code === 'PGRST202' && process.env.NODE_ENV !== 'production') return { allowed: true }; + throw new Error(`Unable to verify check limit: ${error.message}`); + } + const result = Array.isArray(data) ? data[0] : data; + if (!result?.allowed) return { allowed: false, retryAfter: Number(result?.retry_after_seconds || 0), buckets }; + return { allowed: true, buckets }; +} + +async function releaseRateLimit(buckets) { + if (!buckets?.length) return; + const { error } = await getSupabaseAdmin().rpc('release_looney_check_rate_limit', { p_buckets: buckets }); + if (error) throw new Error(`Unable to release check limit: ${error.message}`); +} + +function corsHeaders(request) { + const origin = getHeader(request, 'origin'); + return { + 'Access-Control-Allow-Origin': origin && isAllowedOrigin(origin) ? origin : 'https://renderdragon.org', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Looney-Browser-Id', + 'Access-Control-Max-Age': '86400', + 'Vary': 'Origin', + }; +} + +function jsonResponse(request, body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders(request), 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); +} + +function getLooneyBaseUrl() { + const configuredUrl = new URL(process.env.LOONEY_API_URL || DEFAULT_LOONEY_URL); + configuredUrl.pathname = configuredUrl.pathname.replace(/\/(?:check|jobs)\/?$/, '') || '/'; + configuredUrl.search = ''; + configuredUrl.hash = ''; + return configuredUrl.toString().replace(/\/$/, ''); +} + +function getJobId(request) { + const requestUrl = new URL(request.url || 'http://localhost/api/looney-check'); + const jobId = requestUrl.searchParams.get('job_id'); + if (!jobId || !/^[A-Za-z0-9_-]{1,200}$/.test(jobId)) return null; + return jobId; +} + +function upstreamHeaders(contentType, accept = 'application/json') { + const headers = { Accept: accept }; + if (contentType) headers['Content-Type'] = contentType; + if (process.env.LOONEY_API_KEY) headers['X-API-Key'] = process.env.LOONEY_API_KEY; + return headers; +} + +async function proxyUpstreamResponse(request, response, rewriteStatusUrl = false) { + const responseText = await response.text(); + let responseBody; + try { + responseBody = JSON.parse(responseText); + } catch { + responseBody = { error: 'The copyright service returned an invalid response' }; + } + + if (rewriteStatusUrl && response.ok && responseBody && typeof responseBody === 'object' && responseBody.job_id) { + responseBody = { + ...responseBody, + status_url: `/api/looney-check?job_id=${encodeURIComponent(responseBody.job_id)}`, + }; + } + + return jsonResponse(request, responseBody, response.status); +} + +async function proxyJobEvents(request, jobId) { + const response = await fetch(`${getLooneyBaseUrl()}/jobs/${encodeURIComponent(jobId)}/events`, { + headers: upstreamHeaders(undefined, 'text/event-stream'), + signal: AbortSignal.timeout(300000), + }); + + if (!response.ok || !response.body) return proxyUpstreamResponse(request, response); + + return new Response(response.body, { + status: response.status, + headers: { + ...corsHeaders(request), + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} + +async function readBody(request) { + if (Buffer.isBuffer(request.body) || request.body instanceof Uint8Array) { + return Buffer.from(request.body); + } + + if (request.body && typeof request.body !== 'object') { + return Buffer.from(String(request.body)); + } + + if (!request[Symbol.asyncIterator]) return Buffer.alloc(0); + + const chunks = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk); + total += buffer.length; + if (total > MAX_UPLOAD_BYTES + 1024 * 1024) { + throw new ValidationError('Upload exceeds the 50 MB limit'); + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +async function buildUpstreamRequest(request) { + const contentType = getHeader(request, 'content-type') || ''; + + if (contentType.toLowerCase().startsWith('multipart/form-data')) { + const body = await readBody(request); + if (body.length > MAX_UPLOAD_BYTES + 1024 * 1024) { + throw new ValidationError('Upload exceeds the 50 MB limit'); + } + return { body, contentType }; + } + + let payload = request.body; + if (!payload || typeof payload !== 'object' || Buffer.isBuffer(payload) || (!('spotify_url' in payload) && !('file_url' in payload))) { + const rawBody = await readBody(request); + if (rawBody.length) { + try { + payload = JSON.parse(rawBody.toString('utf8')); + } catch { + throw new ValidationError('Invalid JSON request'); + } + } else { + payload = null; + } + } + + if (!payload || (typeof payload.spotify_url !== 'string' && typeof payload.file_url !== 'string')) { + throw new ValidationError('Provide a Spotify track URL or an audio file'); + } + + if (typeof payload.file_url === 'string') { + let fileUrl; + try { + fileUrl = new URL(payload.file_url); + } catch { + throw new ValidationError('Enter a valid public audio file URL'); + } + if (!['http:', 'https:'].includes(fileUrl.protocol)) throw new ValidationError('Enter a valid public audio file URL'); + return { body: JSON.stringify({ file_url: fileUrl.toString() }), contentType: 'application/json' }; + } + + let spotifyUrl; + try { + spotifyUrl = new URL(payload.spotify_url); + } catch { + throw new ValidationError('Enter a valid Spotify track URL'); + } + + if (spotifyUrl.hostname !== 'open.spotify.com' || !spotifyUrl.pathname.startsWith('/track/')) { + throw new ValidationError('Enter a valid Spotify track URL'); + } + + return { + body: JSON.stringify({ spotify_url: spotifyUrl.toString() }), + contentType: 'application/json', + }; +} + +export const config = { + api: { + bodyParser: false, + sizeLimit: '52mb', + }, + maxDuration: 300, +}; + +class ValidationError extends Error {} + +export default async function handler(request) { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders(request) }); + } + + if (request.method === 'GET') { + const jobId = getJobId(request); + if (!jobId) return jsonResponse(request, { error: 'Missing or invalid job ID' }, 400); + + try { + const requestUrl = new URL(request.url || 'http://localhost/api/looney-check'); + if (requestUrl.searchParams.get('stream') === '1') { + return await proxyJobEvents(request, jobId); + } + + const response = await fetch(`${getLooneyBaseUrl()}/jobs/${encodeURIComponent(jobId)}`, { + headers: upstreamHeaders(), + signal: AbortSignal.timeout(10000), + }); + return proxyUpstreamResponse(request, response); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to read the copyright job'; + return jsonResponse(request, { error: message }, 502); + } + } + + if (request.method !== 'POST') { + return jsonResponse(request, { error: 'Method not allowed' }, 405); + } + + try { + const upstream = await buildUpstreamRequest(request); + const rateLimit = await consumeRateLimit(request); + if (!rateLimit.allowed) { + const retryAfter = String(rateLimit.retryAfter || 86400); + const status = rateLimit.error ? 503 : 429; + return jsonResponse(request, { error: rateLimit.error || `Daily limit reached. You can run up to ${DAILY_CHECK_LIMIT} checks per day.`, retry_after_seconds: Number(retryAfter) }, status); + } + let response; + try { + response = await fetch(`${getLooneyBaseUrl()}/jobs`, { + method: 'POST', + headers: upstreamHeaders(upstream.contentType), + body: upstream.body, + signal: AbortSignal.timeout(30000), + }); + } catch (error) { + try { + await releaseRateLimit(rateLimit.buckets); + } catch (releaseError) { + console.error('Failed to release Looney rate-limit reservation:', releaseError); + } + throw error; + } + if (!response.ok) { + try { + await releaseRateLimit(rateLimit.buckets); + } catch (releaseError) { + console.error('Failed to release Looney rate-limit reservation:', releaseError); + } + return proxyUpstreamResponse(request, response, true); + } + return proxyUpstreamResponse(request, response, true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to check this track'; + const status = error instanceof ValidationError ? 400 : 502; + return jsonResponse(request, { error: message }, status); + } +} diff --git a/public/assets/looney-icon.png b/public/assets/looney-icon.png new file mode 100644 index 0000000..a5397eb Binary files /dev/null and b/public/assets/looney-icon.png differ diff --git a/public/assets/minecraft-pattern-background-1920x1080.png b/public/assets/minecraft-pattern-background-1920x1080.png new file mode 100644 index 0000000..feff69f Binary files /dev/null and b/public/assets/minecraft-pattern-background-1920x1080.png differ diff --git a/server.js b/server.js index ebb8deb..e634719 100644 --- a/server.js +++ b/server.js @@ -7,24 +7,19 @@ import downloadHandler from './api/download.js'; import downloadThumbnailHandler from './api/downloadThumbnail.js'; import generateTitlesHandler from './api/generateTitles.js'; import deleteAccountHandler from './api/deleteAccount.js'; +import looneyCheckHandler from './api/looney-check.js'; +import { isAllowedOrigin } from './api/cors.js'; import { createRouteHandler } from 'uploadthing/express'; import { uploadRouter } from './src/integrations/uploadthing/router.js'; const app = express(); const port = 3000; -const allowedOrigins = [ - 'http://localhost:5173', - 'http://localhost:3000', - 'https://renderdragon.org', - 'https://assets-api-worker.powernplant101-c6b.workers.dev' -]; - app.use(cors({ origin: function (origin, callback) { // allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); - if (allowedOrigins.indexOf(origin) === -1) { + if (!isAllowedOrigin(origin)) { const msg = 'The CORS policy for this site does not allow access from the specified Origin.'; return callback(new Error(msg), false); } @@ -32,14 +27,17 @@ app.use(cors({ }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'] + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Looney-Browser-Id'] })); app.use(express.json()); const createAdapter = (handler) => (req, res) => { const vercelReq = { method: req.method, - headers: req.headers, + headers: { + ...req.headers, + 'x-vercel-ip': req.ip || req.socket.remoteAddress || '', + }, body: req.body, url: `http://${req.headers.host}${req.originalUrl}`, }; @@ -75,6 +73,7 @@ app.all('/api/download', createAdapter(downloadHandler)); app.all('/api/downloadThumbnail', createAdapter(downloadThumbnailHandler)); app.all('/api/generateTitles', createAdapter(generateTitlesHandler)); app.all('/api/deleteAccount', createAdapter(deleteAccountHandler)); +app.all('/api/looney-check', createAdapter(looneyCheckHandler)); // UploadThing route app.use( '/api/uploadthing', @@ -89,4 +88,4 @@ app.use( app.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); -}); \ No newline at end of file +}); diff --git a/src/App.tsx b/src/App.tsx index 07edb08..481a47e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,11 +16,6 @@ import { AdBlockDetector } from "@/components/AdBlockDetector"; import Navbar from "@/components/Navbar"; import Seo from "@/components/Seo"; -const ExternalRedirect = ({ url }: { url: string }) => { - useEffect(() => { window.location.href = url; }, [url]); - return null; -}; - // Global components wrapper to use hooks like useLocation const GlobalComponents = () => { const location = useLocation(); @@ -44,6 +39,7 @@ const ResourcesHub = lazy(() => import("@/pages/ResourcesHub")); const Contact = lazy(() => import("@/pages/Contact")); const BackgroundGenerator = lazy(() => import("@/pages/BackgroundGenerator")); const MusicCopyright = lazy(() => import("@/pages/MusicCopyright")); +const LooneyResultPage = lazy(() => import("@/pages/LooneyResultPage")); const Guides = lazy(() => import("@/pages/Guides")); const GuideView = lazy(() => import("@/pages/GuideView")); const Community = lazy(() => import("@/pages/Community")); @@ -123,12 +119,13 @@ const App = () => { /> } + element={} /> } + element={} /> + } /> } /> } /> } /> diff --git a/src/components/Carousel.tsx b/src/components/Carousel.tsx index 81033a5..466b5d1 100644 --- a/src/components/Carousel.tsx +++ b/src/components/Carousel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import type { JSX } from 'react'; import { motion, PanInfo, useMotionValue, useTransform, MotionValue } from 'framer-motion'; // replace icons with your own if needed -import { IconCircle, IconCode, IconFileText, IconLayers, IconLayout } from '@tabler/icons-react'; +import { IconCircle, IconCode, IconFileText, IconLayout } from '@tabler/icons-react'; import './Carousel.css'; export interface CarouselItem { @@ -40,7 +40,7 @@ export interface CarouselItem { title: 'Components', description: 'Reusable components for your projects.', id: 3, - icon: + icon: }, { title: 'Backgrounds', diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 5cba29c..08766e0 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -25,7 +25,7 @@ class ErrorBoundary extends Component { if (this.state.hasError) { // You can render any custom fallback UI return ( -
+

Something went wrong.

We're sorry for the inconvenience. Please try refreshing the page.

@@ -173,7 +164,7 @@ const Footer = () => {
  • - Music Copyright Checker + Looney Checks
  • diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 710f072..2ee401b 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -14,7 +14,7 @@ const toolPaths = [ const fadeUp = { hidden: { opacity: 0, y: 24 }, - show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } } + show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as const } } } const stagger = { diff --git a/src/components/LooneyCheckDialog.tsx b/src/components/LooneyCheckDialog.tsx new file mode 100644 index 0000000..1db671f --- /dev/null +++ b/src/components/LooneyCheckDialog.tsx @@ -0,0 +1,35 @@ +import { Resource } from '@/types/resources'; +import { useNavigate } from 'react-router-dom'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import LooneyCheckForm from '@/components/LooneyCheckForm'; +import { IconShieldCheck } from '@tabler/icons-react'; +import { useState } from 'react'; +import LooneyRunningCheckDialog from '@/components/LooneyRunningCheckDialog'; +import { LooneyHistoryRecord } from '@/types/looney'; + +interface LooneyCheckDialogProps { + resource: Resource | null; + onClose: () => void; +} + +const LooneyCheckDialog = ({ resource, onClose }: LooneyCheckDialogProps) => { + const navigate = useNavigate(); + const [existingJob, setExistingJob] = useState(null); + const [limitReached, setLimitReached] = useState(false); + return <> { if (!open) { setLimitReached(false); onClose(); } }}> + + + + Check for copyright + + + Looney will identify the track and research licensing signals before returning its findings. + + + { onClose(); if (jobId) navigate(`/gappa/check/${encodeURIComponent(jobId)}`); }} onExistingJob={setExistingJob} onLimitReached={() => setLimitReached(true)} onCheckStart={() => setLimitReached(false)} /> + {limitReached &&

    Two checks are already running. Wait for one to finish before starting another.

    } +
    +
    setExistingJob(null)} />; +}; + +export default LooneyCheckDialog; diff --git a/src/components/LooneyCheckForm.tsx b/src/components/LooneyCheckForm.tsx new file mode 100644 index 0000000..5f6fde5 --- /dev/null +++ b/src/components/LooneyCheckForm.tsx @@ -0,0 +1,277 @@ +import { DragEvent, FormEvent, useEffect, useRef, useState } from 'react'; +import { IconAlertCircle, IconFileMusic, IconLink, IconLoader2, IconUpload } from '@tabler/icons-react'; +import { Resource, getResourceUrl } from '@/types/resources'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { cn } from '@/lib/utils'; +import { checkWithLooney, MAX_LOONEY_FILE_BYTES, refreshRunningLooneyChecks } from '@/utils/looneyChecker'; +import { LooneyHistoryRecord, LooneyResult } from '@/types/looney'; +import { countRunningLooneyChecks, findRunningLooneyCheck, MAX_RUNNING_LOONEY_CHECKS, saveLooneyHistoryRecord, updateLooneyHistoryFromJob, loadLooneyHistory } from '@/utils/looneyHistory'; +import LooneyResultDisplay from '@/components/LooneyResultDisplay'; +import { useUploadThing } from '@/components/UploadThingClient'; + +type SourceTab = 'file' | 'spotify'; +const pendingSourceKeys = new Set(); + +interface LooneyCheckFormProps { + initialResource?: Resource | null; + autoStart?: boolean; + onJobChange?: (jobId: string | null) => void; + onResult?: (result: LooneyResult, sourceType: SourceTab, sourceLabel: string, jobId?: string) => void; + onExistingJob?: (record: LooneyHistoryRecord) => void; + onLimitReached?: () => void; + onCheckStart?: () => void; +} + +const isAudioFile = (file: File) => file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|opus)$/i.test(file.name); + +const LooneyCheckForm = ({ initialResource, autoStart = false, onJobChange, onResult, onExistingJob, onLimitReached, onCheckStart }: LooneyCheckFormProps) => { + const fileInputRef = useRef(null); + const [sourceTab, setSourceTab] = useState('file'); + const [file, setFile] = useState(null); + const [spotifyUrl, setSpotifyUrl] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [progressMessage, setProgressMessage] = useState(''); + const autoRunId = useRef(0); + const { startUpload } = useUploadThing('mediaUploader'); + + const isCurrentAutoRun = (runId?: number) => runId === undefined || runId === autoRunId.current; + + const setSelectedFile = (nextFile: File | null) => { + if (!nextFile) return; + if (!isAudioFile(nextFile)) { + setError('Choose an audio file such as MP3, WAV, M4A, FLAC, OGG, AAC, or OPUS.'); + return; + } + if (nextFile.size > MAX_LOONEY_FILE_BYTES) { + setError('Audio files must be 50 MB or smaller.'); + return; + } + setError(''); + setResult(null); + setFile(nextFile); + setSourceTab('file'); + }; + + const submitCheck = async ( + nextFile?: File, + nextSpotifyUrl?: string, + sourceOverride?: SourceTab, + signal?: AbortSignal, + runId?: number, + nextFileUrl?: string, + ) => { + const selectedFile = nextFile ?? file; + const selectedSpotifyUrl = nextSpotifyUrl ?? spotifyUrl.trim(); + setError(''); + setResult(null); + await refreshRunningLooneyChecks(); + const sourceType = sourceOverride || sourceTab; + const sourceLabel = initialResource?.title || (sourceType === 'file' ? selectedFile?.name : selectedSpotifyUrl) || 'Untitled check'; + const sourceKey = sourceType === 'spotify' + ? `spotify:${(selectedSpotifyUrl.match(/track\/([a-zA-Z0-9]+)/)?.[1] || selectedSpotifyUrl).toLowerCase()}` + : `file:${sourceLabel.toLowerCase().trim()}`; + const existingJob = findRunningLooneyCheck(sourceKey); + if (existingJob) { + setIsLoading(false); + setProgressMessage(''); + onExistingJob?.(existingJob); + return; + } + if (pendingSourceKeys.has(sourceKey) || countRunningLooneyChecks() + pendingSourceKeys.size >= MAX_RUNNING_LOONEY_CHECKS) { + setIsLoading(false); + setProgressMessage(''); + onLimitReached?.(); + return; + } + pendingSourceKeys.add(sourceKey); + onCheckStart?.(); + setProgressMessage('Connecting to Looney...'); + setIsLoading(true); + + try { + let historyJobId: string | null = null; + let fileUrl = nextFileUrl; + if (selectedFile && !fileUrl) { + setProgressMessage('Uploading audio securely...'); + const uploaded = await startUpload([selectedFile]); + fileUrl = uploaded?.[0]?.ufsUrl || uploaded?.[0]?.url; + if (!fileUrl) throw new Error('The audio upload did not return a public file URL.'); + } + if (sourceType === 'file' && !fileUrl) throw new Error('No public file URL was available for this audio check.'); + const response = await checkWithLooney( + sourceType === 'file' ? { fileUrl } : { spotifyUrl: selectedSpotifyUrl }, + signal, + { + onJobCreated: (job) => { + const record: LooneyHistoryRecord = { + jobId: job.job_id, + sourceLabel, + sourceType, + createdAt: new Date().toISOString(), + status: job.status, + sourceKey, + }; + historyJobId = job.job_id; + saveLooneyHistoryRecord(record); + pendingSourceKeys.delete(sourceKey); + onJobChange?.(job.job_id); + }, + onJobUpdate: (job) => { + const existing = loadLooneyHistory().find((record) => record.jobId === job.job_id); + if (existing) saveLooneyHistoryRecord(updateLooneyHistoryFromJob(existing, job)); + }, + onProgress: (message) => { + if (!isCurrentAutoRun(runId)) return; + setProgressMessage(message); + if (historyJobId) { + const existing = loadLooneyHistory().find((record) => record.jobId === historyJobId); + if (existing) saveLooneyHistoryRecord({ ...existing, progress: message }); + } + }, + }, + ); + if (!isCurrentAutoRun(runId) || signal?.aborted) return; + setResult(response); + onResult?.(response, sourceType, sourceLabel, historyJobId || undefined); + } catch (checkError) { + if (signal?.aborted || !isCurrentAutoRun(runId)) return; + setError(checkError instanceof Error ? checkError.message : 'Unable to check this track.'); + } finally { + pendingSourceKeys.delete(sourceKey); + if (isCurrentAutoRun(runId)) { + setIsLoading(false); + setProgressMessage(''); + onJobChange?.(null); + } + } + }; + + const loadResource = async (resource: Resource, signal: AbortSignal, runId: number) => { + const resourceUrl = getResourceUrl(resource); + if (!resourceUrl) { + setError('This resource does not have an audio file to check.'); + setIsLoading(false); + return; + } + + try { + const publicResourceUrl = new URL(resourceUrl, window.location.origin).toString(); + if (!isCurrentAutoRun(runId) || signal.aborted) return; + await submitCheck(undefined, undefined, 'file', signal, runId, publicResourceUrl); + } catch (loadError) { + if (signal.aborted || !isCurrentAutoRun(runId)) return; + setIsLoading(false); + setError(loadError instanceof Error ? loadError.message : 'Unable to load this resource.'); + } + }; + + useEffect(() => { + if (!autoStart || !initialResource) return; + const runId = ++autoRunId.current; + const controller = new AbortController(); + setSourceTab('file'); + setFile(null); + setResult(null); + setError(''); + setProgressMessage(''); + setIsLoading(true); + const timer = window.setTimeout(() => void loadResource(initialResource, controller.signal, runId), 0); + return () => { window.clearTimeout(timer); controller.abort(); }; + // The dialog creates a new check when the selected resource changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoStart, initialResource]); + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setSelectedFile(event.dataTransfer.files[0] || null); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (sourceTab === 'file' && !file) { + setError('Choose an audio file first.'); + return; + } + if (sourceTab === 'spotify' && !spotifyUrl.trim()) { + setError('Enter a Spotify track URL first.'); + return; + } + void submitCheck(); + }; + + return ( +
    + {!autoStart && ( +
    + + +
    + )} + + {!autoStart && sourceTab === 'file' ? ( + <> + setSelectedFile(event.target.files?.[0] || null)} /> +
    fileInputRef.current?.click()} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + fileInputRef.current?.click(); + }} + role="button" + tabIndex={0} + aria-label="Choose an audio file to check" + onDragOver={(event) => event.preventDefault()} + onDrop={handleDrop} + className="pixel-corners cursor-pointer border-2 border-dashed border-cow-purple/50 bg-cow-purple/5 p-8 text-center transition-colors hover:border-cow-purple hover:bg-cow-purple/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cow-purple" + > + +

    {file ? file.name : 'Drop an audio file here or browse'}

    +

    MP3, WAV, M4A, FLAC, OGG, AAC, or OPUS up to 50 MB

    +
    + + ) : !autoStart ? ( +
    + + setSpotifyUrl(event.target.value)} placeholder="https://open.spotify.com/track/..." className="pixel-corners" /> +

    Use a public Spotify track link. The audio is processed by the Looney API.

    +
    + ) : null} + + {error && ( + + + Check could not start + {error} + + )} + + {!autoStart && ( + + )} + + {autoStart && isLoading && ( +
    + +

    {progressMessage || 'Looney is researching this track...'}

    + {initialResource &&

    {initialResource.title}

    } +

    This can take a few minutes while sources are checked.

    +
    + )} + + {result && !isLoading && !onResult && } + + ); +}; + +export default LooneyCheckForm; diff --git a/src/components/LooneyHistorySection.tsx b/src/components/LooneyHistorySection.tsx new file mode 100644 index 0000000..cf012e8 --- /dev/null +++ b/src/components/LooneyHistorySection.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; +import { IconActivity, IconAlertTriangle, IconClock, IconHistory, IconTrash, IconCircleCheck } from '@tabler/icons-react'; +import { Button } from '@/components/ui/button'; +import { LooneyHistoryRecord } from '@/types/looney'; +import { streamLooneyJob } from '@/utils/looneyChecker'; +import { clearLooneyHistory, loadLooneyHistory, looneyHistoryUpdateEvent, saveLooneyHistoryRecord, updateLooneyHistoryFromJob } from '@/utils/looneyHistory'; + +interface LooneyHistorySectionProps { + activeJobId?: string | null; + onSelectRecord?: (record: LooneyHistoryRecord) => void; +} + +const isRunning = (record: LooneyHistoryRecord) => record.status === 'queued' || record.status === 'running'; + +const formatDate = (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString(); +}; + +const LooneyHistorySection = ({ activeJobId, onSelectRecord }: LooneyHistorySectionProps) => { + const [records, setRecords] = useState(() => loadLooneyHistory()); + const recordsRef = useRef(records); + + useEffect(() => { + recordsRef.current = records; + }, [records]); + + useEffect(() => { + const refresh = () => setRecords(loadLooneyHistory()); + window.addEventListener(looneyHistoryUpdateEvent, refresh); + window.addEventListener('storage', refresh); + return () => { + window.removeEventListener(looneyHistoryUpdateEvent, refresh); + window.removeEventListener('storage', refresh); + }; + }, []); + + useEffect(() => { + const controllers = new Set(); + let cancelled = false; + const streamRunningJobs = async () => { + const runningRecords = recordsRef.current.filter((record) => isRunning(record) && record.jobId !== activeJobId); + await Promise.all(runningRecords.map(async (record) => { + const controller = new AbortController(); + controllers.add(controller); + const currentRecord = () => loadLooneyHistory().find((item) => item.jobId === record.jobId) || record; + try { + const job = await streamLooneyJob(record.jobId, controller.signal, (message) => { + saveLooneyHistoryRecord({ ...currentRecord(), progress: message }); + }); + const updated = updateLooneyHistoryFromJob(currentRecord(), job); + saveLooneyHistoryRecord(updated); + } catch (error) { + if (!controller.signal.aborted) { + saveLooneyHistoryRecord({ + ...currentRecord(), + status: 'failed', + error: error instanceof Error ? error.message : 'Unable to resume this check.', + }); + } + } finally { + controllers.delete(controller); + } + })); + if (!cancelled) setRecords(loadLooneyHistory()); + }; + + void streamRunningJobs(); + return () => { + cancelled = true; + controllers.forEach((controller) => controller.abort()); + }; + }, [activeJobId]); + + const runningRecords = records.filter(isRunning); + const historyRecords = records.filter((record) => !isRunning(record)); + + return ( +
    +
    +
    +
    + +
    +
    +

    Check history

    +

    Jobs continue on the server while this tab is closed.

    +
    +
    + {records.length > 0 && ( + + )} +
    + +
    +
    +

    Running checks

    + {runningRecords.length === 0 ? ( +

    No checks are currently running.

    + ) : ( +
    + {runningRecords.map((record) => ( +
    +
    + {record.sourceLabel} + {record.status} +
    +

    {record.progress || `Started ${formatDate(record.createdAt)}`}

    +
    + ))} +
    + )} +
    + +
    +

    Previous checks

    + {historyRecords.length === 0 ? ( +

    Your completed checks will appear here.

    + ) : ( +
    + {historyRecords.map((record) => ( + + ))} +
    + )} +
    +
    + +
    + ); +}; + +export default LooneyHistorySection; diff --git a/src/components/LooneyResultDisplay.tsx b/src/components/LooneyResultDisplay.tsx new file mode 100644 index 0000000..133660e --- /dev/null +++ b/src/components/LooneyResultDisplay.tsx @@ -0,0 +1,57 @@ +import { IconAlertTriangle, IconBrandSpotify, IconCircleCheck, IconExternalLink, IconFileMusic, IconLink, IconShieldCheck } from '@tabler/icons-react'; +import { LooneyResult, LooneyValue } from '@/types/looney'; + +const labelize = (value: string) => value.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +const primitive = (value: LooneyValue): string | null => value === null || typeof value === 'object' ? null : String(value); +const isUrl = (value: string) => /^https?:\/\//i.test(value); +const hostLabel = (value: string) => { try { return new URL(value).hostname.replace(/^www\./, '').split('.')[0]; } catch { return 'Open source'; } }; +const sourceLabelKeys = ['name', 'source', 'site', 'website', 'publisher', 'provider', 'title', 'source_name', 'site_name', 'outlet']; +const hasContent = (value: LooneyValue | undefined) => { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.some(hasContent); + if (typeof value === 'object') return Object.values(value).some(hasContent); + return true; +}; + +const SourceLink = ({ value, spotify = false, label }: { value: string; spotify?: boolean; label?: string }) => { + let iconUrl = ''; + try { iconUrl = `${new URL(value).origin}/favicon.ico`; } catch { /* fallback icon below */ } + return {iconUrl && { event.currentTarget.style.display = 'none'; }} />}{spotify ? 'Open in Spotify' : label || hostLabel(value)}{spotify ? : }; +}; + +const ResultValue = ({ value, linkLabel }: { value: LooneyValue; linkLabel?: string }) => { + const text = primitive(value); + if (text !== null) return isUrl(text) ? : {text}; + if (Array.isArray(value)) return
    {value.map((item, index) =>
    )}
    ; + const objectLabel = Object.entries(value || {}).find(([key, item]) => sourceLabelKeys.includes(key.toLowerCase()) && primitive(item) !== null)?.[1]; + const objectLinkLabel = objectLabel === undefined ? undefined : primitive(objectLabel) || undefined; + return
    {Object.entries(value || {}).map(([key, item]) =>
    {labelize(key)}
    )}
    ; +}; + +const ResultSection = ({ title, value, warning = false }: { title: string; value?: LooneyValue; warning?: boolean }) => { + if (!hasContent(value)) return null; + return

    {title}

    ; +}; + +const TrackDetails = ({ track }: { track?: { [key: string]: LooneyValue } }) => { + if (!track) return null; + const entries = Object.entries(track).filter(([, value]) => primitive(value) !== null); + if (!entries.length) return null; + const trackLabel = entries.find(([key]) => sourceLabelKeys.includes(key.toLowerCase()))?.[1]; + const trackLinkLabel = trackLabel === undefined ? undefined : primitive(trackLabel) || undefined; + return
    {entries.map(([key, value]) => { const raw = primitive(value) || ''; const parsedDate = key.toLowerCase().includes('date') ? new Date(raw) : null; const display = key.toLowerCase().includes('duration') && Number.isFinite(Number(raw)) ? `${Math.floor(Number(raw) / 60000)} min ${Math.round((Number(raw) % 60000) / 1000)} sec` : parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : raw; return

    {labelize(key)}

    {isUrl(raw) ? : display}
    ; })}
    ; +}; + +interface LooneyResultDisplayProps { result: LooneyResult; sourceType?: 'file' | 'spotify'; sourceLabel?: string; onClose?: () => void; } + +const LooneyResultDisplay = ({ result, sourceType = 'file', sourceLabel, onClose }: LooneyResultDisplayProps) => { + const status = result.research?.status || 'complete'; + const title = sourceType === 'file' && sourceLabel ? sourceLabel : result.request?.track?.title || result.request?.track?.name || 'Track analysis'; + const spotify = sourceType === 'spotify'; + return
    +
    {status === 'complete' ? : status === 'partial' ? : }{labelize(status)} check{spotify ? 'Spotify link' : 'Audio file'}

    {String(title)}

    {sourceLabel && sourceLabel !== title && (spotify && isUrl(sourceLabel) ?
    :

    {sourceLabel}

    )}
    {onClose && }
    {result.research?.summary &&

    {result.research.summary}

    }
    +
    {spotify ? : }{spotify ? 'Assessment' : 'File check result'}
    {spotify ? : }{spotify ? 'Track details' : 'File details'}
    +
    ; +}; + +export default LooneyResultDisplay; diff --git a/src/components/LooneyRunningCheckDialog.tsx b/src/components/LooneyRunningCheckDialog.tsx new file mode 100644 index 0000000..0ba1b46 --- /dev/null +++ b/src/components/LooneyRunningCheckDialog.tsx @@ -0,0 +1,21 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { IconActivity, IconArrowUpRight } from '@tabler/icons-react'; +import { Link } from 'react-router-dom'; +import { LooneyHistoryRecord } from '@/types/looney'; + +interface LooneyRunningCheckDialogProps { + record: LooneyHistoryRecord | null; + onClose: () => void; +} + +const LooneyRunningCheckDialog = ({ record, onClose }: LooneyRunningCheckDialogProps) => !open && onClose()}> + + + Check already running + This source already has an active Looney check. Starting another one would duplicate the work. + + {record &&

    {record.sourceLabel}

    {record.progress || 'Looney is researching this source.'}

    Open running check
    } +
    +
    ; + +export default LooneyRunningCheckDialog; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9ef985c..f3bc948 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -57,7 +57,7 @@ const mainLinks: (NavLink | NavDropdown)[] = [ name: 'Tools', icon: 'tools', links: [ - { name: 'Music Copyright Checker', path: '/gappa', icon: 'music' }, + { name: 'Looney Checks', path: '/gappa', icon: 'music' }, { name: 'Background Generator', path: '/background-generator', icon: 'background' }, { name: 'Player Renderer', path: '/player-renderer', icon: 'player' }, { name: 'Text Generator', path: '/text-generator', icon: 'text' }, diff --git a/src/components/PopularTools.tsx b/src/components/PopularTools.tsx index ae2c90b..2b924af 100644 --- a/src/components/PopularTools.tsx +++ b/src/components/PopularTools.tsx @@ -1,14 +1,26 @@ import React from "react" import { Link } from "react-router-dom" import { motion } from 'framer-motion' -import { IconArrowRight, IconDownload, IconMusic, IconRobot, IconPhoto, IconUser } from '@tabler/icons-react' +import { IconArrowRight, IconBrandYoutube, IconDownload, IconMusic, IconPhoto, IconUser } from '@tabler/icons-react' -const tools = [ +type Tool = { + id: number; + title: string; + description: string; + icon: typeof IconMusic; + path: string; + hoverIcon?: typeof IconBrandYoutube; + hoverImage?: string; + backgroundImage?: string; +}; + +const tools: Tool[] = [ { id: 1, - title: 'Copyright Checker', - description: 'Check if a song is safe to use before it gets your video copyright-striked.', + title: 'Looney Checks', + description: 'Research a track before it gets your video copyright-striked.', icon: IconMusic, + hoverImage: '/assets/looney-icon.png', path: '/music-copyright', }, { @@ -16,6 +28,7 @@ const tools = [ title: 'YouTube Tools', description: 'Grab thumbnails, peek at analytics, and download videos for reference.', icon: IconDownload, + hoverIcon: IconBrandYoutube, path: '/youtube-downloader', }, { @@ -23,6 +36,7 @@ const tools = [ title: 'Background Gen', description: 'Generate stunning, unique backgrounds for your thumbnails in seconds.', icon: IconPhoto, + backgroundImage: '/assets/minecraft-pattern-background-1920x1080.png', path: '/background-generator', }, { @@ -30,6 +44,7 @@ const tools = [ title: 'Player Renderer', description: 'Render a 3D model of any Minecraft player skin. Pose it. Screenshot it.', icon: IconUser, + hoverImage: 'https://vzge.me/face/1024/codersoft', path: '/player-renderer', }, ] @@ -41,7 +56,7 @@ const containerVariants = { const itemVariants = { hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } } + visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const } } } const PopularTools = () => { @@ -76,21 +91,28 @@ const PopularTools = () => { > {tools.map((tool) => ( - -
    -
    - + + {tool.backgroundImage && <> + + +
    + + +
    @@ -61,7 +74,7 @@ const MinecraftNametagGenerator = () => {
    {playerName ? ( void; + onCheckCopyright?: (resource: Resource) => void; } -const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { +const getPreviewUrl = (resource: Resource) => { + if (resource.download_url) return resource.download_url; + + if (!resource.title) return ""; + const titleLowered = resource.title.toLowerCase().replace(/ /g, "%20"); + const basePath = "https://raw.githubusercontent.com/Yxmura/resources_renderdragon/main"; + const creditPart = resource.credit ? `__${resource.credit.replace(/ /g, "_")}` : ""; + return `${basePath}/${resource.category}/${titleLowered}${creditPart}.${resource.filetype}`; +}; + +const ResourceCard = ({ resource, onClick, onCheckCopyright }: ResourceCardProps) => { const [isImageLoaded, setIsImageLoaded] = useState(false); // Reset image loaded state when resource changes @@ -28,20 +39,6 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { const { toggleFavorite, isFavorited } = useUserFavorites(); const isFavorite = isFavorited(String(resource.id)); - const getPreviewUrl = (resource: Resource) => { - if (resource.download_url) return resource.download_url; - - // Fallback - if (!resource.title) return ""; - const titleLowered = resource.title.toLowerCase().replace(/ /g, "%20"); - const basePath = - "https://raw.githubusercontent.com/Yxmura/resources_renderdragon/main"; - const creditPart = resource.credit - ? `__${resource.credit.replace(/ /g, "_")}` - : ""; - return `${basePath}/${resource.category}/${titleLowered}${creditPart}.${resource.filetype}`; - }; - const [isInView, setIsInView] = useState(false); const [isFontLoaded, setIsFontLoaded] = useState(false); const cardRef = useRef(null); @@ -69,37 +66,38 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { useEffect(() => { let active = true; setIsFontLoaded(false); - if (resource.category !== "fonts" || !resource.download_url) { + if (resource.category !== "fonts") { return () => { active = false; }; } - const fontUrl = resource.download_url; - const fontName = resource.title; - const styleId = `font-preview-${resource.id}-${btoa(fontName + fontUrl).slice(0, 12)}`; - - if (document.fonts.check(`1em "${fontName}"`)) { - if (active) setIsFontLoaded(true); - return; + const fontUrl = resource.download_url || (resource.title + ? `https://raw.githubusercontent.com/Yxmura/resources_renderdragon/main/${resource.category}/${resource.title.toLowerCase().replace(/ /g, "%20")}${resource.credit ? `__${resource.credit.replace(/ /g, "_")}` : ""}.${resource.filetype}` + : ""); + if (!fontUrl) { + return () => { active = false; }; } + const fontName = resource.title; + const maybeLoadFont = () => { - if (document.fonts.check(`1em "${fontName}"`)) { + if (document.fonts.check(`12px "${fontName}"`)) { if (active) setIsFontLoaded(true); return; } - - if (!document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - const escapedName = fontName.replace(/["'\\]/g, ''); - style.textContent = `@font-face { font-family: "${escapedName}"; src: url("${fontUrl}"); font-display: swap; }`; - document.head.appendChild(style); + let fontFace: FontFace; + try { + const safeFontName = fontName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const safeFontUrl = encodeURI(fontUrl).replace(/"/g, '%22'); + fontFace = new FontFace(safeFontName, `url("${safeFontUrl}")`); + } catch (error) { + if (active) console.error(`Invalid font descriptor for "${fontName}":`, error); + return; } - - document.fonts.load(`1em "${fontName}"`).then(() => { + fontFace.load().then((loadedFont) => { + document.fonts.add(loadedFont); if (active) setIsFontLoaded(true); - }).catch(() => { - setTimeout(() => { if (active) setIsFontLoaded(true); }, 3000); + }).catch((error) => { + if (active) console.error(`Failed to load font "${fontName}":`, error); }); }; @@ -110,7 +108,7 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { return () => { active = false; clearTimeout(timer); }; } return () => { active = false; }; - }, [resource.id, resource.download_url, resource.category, resource.title, isInView]); + }, [resource.category, resource.title, resource.download_url, resource.credit, resource.filetype, isInView]); const handlePreviewClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -122,6 +120,12 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { toggleFavorite(String(resource.id)); }; + const handleCopyrightClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onCheckCopyright?.(resource); + }; + const renderPreview = () => { const previewUrl = getPreviewUrl(resource); @@ -183,6 +187,19 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => { isInView={isInView} className="w-full shadow-none border-none bg-transparent p-0" /> + {(resource.category === "music" || resource.category === "minecraft-music") && onCheckCopyright && ( + + )}
    ); case "minecraft-music": diff --git a/src/components/resources/ResourcesList.tsx b/src/components/resources/ResourcesList.tsx index 24c8c65..64deced 100644 --- a/src/components/resources/ResourcesList.tsx +++ b/src/components/resources/ResourcesList.tsx @@ -26,7 +26,7 @@ interface ResourcesListProps { onClearFilters: () => void; hasCategoryResources: boolean; filteredResources: Resource[]; - fontPreviewText?: string; + onCheckCopyright?: (resource: Resource) => void; } const ResourcesList = ({ @@ -39,7 +39,7 @@ const ResourcesList = ({ onClearFilters, hasCategoryResources, filteredResources, - fontPreviewText, + onCheckCopyright, }: ResourcesListProps) => { const [currentPage, setCurrentPage] = React.useState(1); const itemsPerPage = 12; @@ -180,11 +180,11 @@ const ResourcesList = ({ animate={{ opacity: 1, y: 0 }} transition={{ delay: (index % itemsPerPage) * 0.05, duration: 0.3 }} > - +
    ))} @@ -258,4 +258,4 @@ const ResourcesList = ({ ); }; -export default React.memo(ResourcesList); \ No newline at end of file +export default React.memo(ResourcesList); diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index afe5da6..fea09d7 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -3,7 +3,7 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" import { type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" -import { toggleVariants } from "@/components/ui/toggle" +import { toggleVariants } from "@/components/ui/toggle-variants" const ToggleGroupContext = React.createContext< VariantProps @@ -14,13 +14,14 @@ const ToggleGroupContext = React.createContext< const ToggleGroup = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & + Omit, "type"> & VariantProps >(({ className, variant, size, children, ...props }, ref) => ( {children} @@ -34,7 +35,7 @@ const ToggleGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps ->(({ className, children, variant, size, ...props }, ref) => { +>(({ className, children, variant, size, value, ...props }, ref) => { const context = React.useContext(ToggleGroupContext) return ( @@ -47,6 +48,7 @@ const ToggleGroupItem = React.forwardRef< }), className )} + value={value} {...props} > {children} diff --git a/src/hooks/useMinecraftMusic.ts b/src/hooks/useMinecraftMusic.ts index e588076..6d063dd 100644 --- a/src/hooks/useMinecraftMusic.ts +++ b/src/hooks/useMinecraftMusic.ts @@ -16,7 +16,7 @@ interface PlaylistResponse { files: PlaylistFile[]; } -let globalCachePromise: Promise | null = null; +let globalCachePromise: Promise | null = null; let albumMapPromise: Promise> | null = null; const fetchAlbumMap = async (): Promise> => { diff --git a/src/index.css b/src/index.css index c19ea72..bfa73a4 100644 --- a/src/index.css +++ b/src/index.css @@ -14,6 +14,12 @@ font-display: swap; } +@font-face { + font-family: 'Minecraft Five Bold'; + src: url('https://raw.githubusercontent.com/Yxmura/resources_renderdragon/d41fc385c3c178527da0710ad4f6cd4f37a12eea/fonts/minecraft.ttf') format('truetype'); + font-display: swap; +} + @layer base { :root { --background: 230 20% 98%; @@ -189,6 +195,16 @@ font-family: 'Minecraft Seven', monospace; } +.font-minecraft-five { + font-family: 'Minecraft Five Bold', monospace; +} + +.tool-planks-hover { + background-color: #8b5a35; + background-image: linear-gradient(90deg, rgba(48, 25, 12, 0.22) 1px, transparent 1px), linear-gradient(rgba(255, 210, 150, 0.16) 1px, transparent 1px); + background-size: 144px 100%, 100% 34px; +} + .font-jetbrains-mono { font-family: 'JetBrains Mono', monospace; } diff --git a/src/integrations/supabase/types.d.ts b/src/integrations/supabase/types.d.ts index dc5d697..2c23ab8 100644 --- a/src/integrations/supabase/types.d.ts +++ b/src/integrations/supabase/types.d.ts @@ -1,12 +1 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -type Json = any; -export type Database = { - public: { - Tables: any; - Views: any; - Functions: any; - Enums: any; - CompositeTypes: any; - }; -}; +export type { Database, Json } from './types'; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts new file mode 100644 index 0000000..60c701d --- /dev/null +++ b/src/integrations/supabase/types.ts @@ -0,0 +1,181 @@ +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +type Table> = { + Row: Row; + Insert: Partial; + Update: Partial; + Relationships: []; +}; + +type ResourceCategory = 'music' | 'sfx' | 'images' | 'animations' | 'fonts' | 'presets'; +type ResourceSubcategory = 'davinci' | 'adobe'; + +type Blog = { + id: string; + title: string; + slug: string; + content: string | null; + published: boolean; + author_id: string; + created_at: string; + updated_at: string; + [key: string]: Json | undefined; +}; + +type CreatorPack = { + id: string; + user_id: string; + title: string; + slug: string; + small_description: string; + description: string; + cover_image_url: string | null; + external_link: string; + tags: string[]; + status: 'pending' | 'approved' | 'rejected'; + review_reason: string | null; + created_at: string; + [key: string]: Json | undefined; +}; + +type Profile = { + id: string; + email: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; + username: string | null; + avatar_url: string | null; + bio: string | null; + links: Json; + social_links: Json; + theme_config: Json; + verified: boolean; + created_at: string; + updated_at: string; + [key: string]: Json | undefined; +}; + +type Resource = { + id: number; + title: string; + category: ResourceCategory; + subcategory: ResourceSubcategory | null; + credit: string | null; + filetype: string | null; + software: string | null; + image_url: string | null; + description: string | null; + preview_url: string | null; + download_url: string | null; + created_at: string | null; + updated_at: string | null; + [key: string]: Json | undefined; +}; + +type UserFavorite = { + id: string; + user_id: string; + resource_url: string | null; + resource_id: string | null; + folder_id: string | null; + created_at: string; + [key: string]: Json | undefined; +}; + +type FavoriteFolder = { + id: string; + user_id: string; + name: string; + parent_id: string | null; + color: string | null; + created_at: string; + [key: string]: Json | undefined; +}; + +type ShowcasePage = { + id: string; + owner_id: string; + slug: string; + title: string | null; + about: string | null; + theme: Json; + layout: Json; + cover_image_path: string | null; + avatar_image_path: string | null; + status: 'draft' | 'published' | 'unlisted'; + created_at: string; + updated_at: string; + [key: string]: Json | undefined; +}; + +type ShowcaseMedia = { + id: string; + page_id: string; + kind: 'image'; + path: string; + position: number; + created_at: string; + [key: string]: Json | undefined; +}; + +type LooneyRateLimit = { + id: number; + bucket_type: 'browser' | 'ip' | 'account'; + bucket_hash: string; + user_id: string | null; + window_started_at: string; + check_count: number; + last_check_at: string; +}; + +export type Database = { + public: { + Tables: { + blogs: Table; + creator_packs: Table; + creator_packs_covers: Table<{ id: string; creator_pack_id: string; path: string; created_at: string; [key: string]: Json | undefined }>; + downloads: Table<{ id: number; count: number | null; resource_id: number | null }>; + profiles: Table; + resources: Table; + showcase_media: Table; + showcase_pages: Table; + user_favorite_folders: Table; + user_favorites: Table; + looney_check_rate_limits: Table; + }; + Views: Record; + Functions: { + consume_looney_check_rate_limit: { + Args: { p_buckets: Json; p_limit?: number; p_consume?: boolean }; + Returns: Array<{ + allowed: boolean; + retry_after_seconds: number; + browser_count: number; + ip_count: number; + account_count: number; + }>; + }; + release_looney_check_rate_limit: { + Args: { p_buckets: Json }; + Returns: undefined; + }; + get_my_profile: { + Args: Record; + Returns: Json; + }; + }; + Enums: { + resource_category: ResourceCategory; + resource_subcategory: ResourceSubcategory; + }; + CompositeTypes: Record; + }; +}; + +type DefaultSchema = Database['public']; + +export type Tables = DefaultSchema['Tables'][TableName]['Row']; +export type TablesInsert = DefaultSchema['Tables'][TableName]['Insert']; +export type TablesUpdate = DefaultSchema['Tables'][TableName]['Update']; +export type Enums = DefaultSchema['Enums'][EnumName]; diff --git a/src/pages/BackgroundGenerator.tsx b/src/pages/BackgroundGenerator.tsx index 1d13520..9807cf8 100644 --- a/src/pages/BackgroundGenerator.tsx +++ b/src/pages/BackgroundGenerator.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; import { Button } from "@/components/ui/button"; @@ -18,6 +18,11 @@ import { IconUpload, IconTrash, IconPhoto, + IconSearch, + IconX, + IconPlayerPlay, + IconPlayerStop, + IconRotateClockwise, } from "@tabler/icons-react"; import { toast } from "sonner"; import { Helmet } from "react-helmet-async"; @@ -25,28 +30,146 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; type Texture = { id: number | string; url: string; title: string; subcategory?: string }; +type PatternImage = { id: string; url: string; title: string; source: "library" | "upload" }; +type PatternType = "grid" | "staggered" | "diagonal" | "scattered" | "random"; +type OutputMode = "image" | "video"; + +const MAX_UPLOADED_IMAGES = 20; +const MAX_IMAGE_FILE_BYTES = 10 * 1024 * 1024; + const isTexture = (value: unknown): value is Texture => { if (!value || typeof value !== 'object') return false; const texture = value as Record; return (typeof texture.id === 'number' || typeof texture.id === 'string') && typeof texture.url === 'string' && typeof texture.title === 'string' && texture.subcategory === 'textures'; }; +const normalizeSearchText = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + +const editDistance = (left: string, right: string) => { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + let diagonal = previous[0]; + previous[0] = leftIndex; + + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const above = previous[rightIndex]; + previous[rightIndex] = left[leftIndex - 1] === right[rightIndex - 1] + ? diagonal + : Math.min(diagonal, previous[rightIndex - 1], above) + 1; + diagonal = above; + } + } + + return previous[right.length]; +}; + +const fuzzyTextureScore = (texture: Texture, query: string) => { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return 0; + + const title = normalizeSearchText(texture.title); + const words = title.split(" ").filter(Boolean); + if (title === normalizedQuery) return 0; + if (title.startsWith(normalizedQuery)) return 1; + if (title.includes(normalizedQuery)) return 2; + + const queryWords = normalizedQuery.split(" ").filter(Boolean); + const wordScore = queryWords.reduce((total, queryWord) => { + const bestWordScore = words.reduce((best, word) => { + if (word.startsWith(queryWord)) return Math.min(best, 3); + if (word.includes(queryWord)) return Math.min(best, 4); + if (queryWord.length >= 3 && editDistance(word, queryWord) <= 2) return Math.min(best, 5); + return best; + }, Infinity); + + return total + bestWordScore; + }, 0); + + if (wordScore !== Infinity && wordScore <= queryWords.length * 5) return 10 + wordScore; + + let queryIndex = 0; + for (const character of title) { + if (character === normalizedQuery[queryIndex]) queryIndex += 1; + if (queryIndex === normalizedQuery.length) return 20 + title.length - normalizedQuery.length; + } + + return Infinity; +}; + +const createRandom = (seed: number) => { + let value = seed >>> 0; + return () => { + value = (value * 1664525 + 1013904223) >>> 0; + return value / 4294967296; + }; +}; + const BackgroundGenerator = () => { const [color, setColor] = useState("#9b87f5"); const [size, setSize] = useState("1920x1080"); const [spacing, setSpacing] = useState([0]); const [opacity, setOpacity] = useState([100]); const [scale, setScale] = useState([100]); - const [isTransparent, setIsTransparent] = useState(false); + const [isTransparent, setIsTransparent] = useState(true); const [isGenerating, setIsGenerating] = useState(false); const [generatedImage, setGeneratedImage] = useState(null); - const [uploadedImage, setUploadedImage] = useState(null); + const [uploadedImages, setUploadedImages] = useState([]); const [textures, setTextures] = useState([]); const [visibleTexturesCount, setVisibleTexturesCount] = useState(40); - const [selectedTexture, setSelectedTexture] = useState(null); + const [textureSearch, setTextureSearch] = useState(""); + const [selectedImages, setSelectedImages] = useState([]); + const [patternType, setPatternType] = useState("grid"); + const [randomSeed, setRandomSeed] = useState(() => Date.now()); + const [outputMode, setOutputMode] = useState("image"); + const [rotation, setRotation] = useState([0]); + const [animationDuration, setAnimationDuration] = useState([4]); + const [animationFps, setAnimationFps] = useState([12]); + const [animationDistance, setAnimationDistance] = useState([160]); + const [videoUrl, setVideoUrl] = useState(null); + const [isRecording, setIsRecording] = useState(false); + const [recordingProgress, setRecordingProgress] = useState(0); const [isLoadingTextures, setIsLoadingTextures] = useState(true); + const [previewWidth, previewHeight] = size.split("x").map((dimension) => parseInt(dimension, 10)); + const spacingValue = spacing[0]; + const opacityValue = opacity[0]; + const scaleValue = scale[0]; + const rotationValue = rotation[0]; + const animationDurationValue = animationDuration[0]; + const animationFpsValue = animationFps[0]; + const animationDistanceValue = animationDistance[0]; const fileInputRef = useRef(null); const canvasRef = useRef(null); + const uploadIdRef = useRef(0); + const generationIdRef = useRef(0); + const recordingTimerRef = useRef | null>(null); + + const invalidateGeneration = () => { + generationIdRef.current += 1; + setGeneratedImage((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + setVideoUrl((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + setIsGenerating(false); + }; + + const filteredTextures = useMemo(() => ( + textureSearch.trim() + ? textures + .map((texture) => ({ texture, score: fuzzyTextureScore(texture, textureSearch) })) + .filter(({ score }) => score !== Infinity) + .sort((left, right) => left.score - right.score || left.texture.title.localeCompare(right.texture.title)) + .map(({ texture }) => texture) + : textures + ), [textures, textureSearch]); + + useEffect(() => { + setVisibleTexturesCount(40); + }, [textureSearch]); useEffect(() => { const fetchTextures = async () => { @@ -71,126 +194,380 @@ const BackgroundGenerator = () => { }, []); const handleImageUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const files = Array.from(event.target.files ?? []); + const imageFiles = files.filter((file) => file.type.startsWith("image/")); - // Check if file is an image - if (!file.type.startsWith("image/")) { - toast.error("Please upload an image file"); - return; + if (imageFiles.length !== files.length) { + toast.error("Only image files can be added"); + } + const validSizeFiles = imageFiles.filter((file) => { + if (file.size <= MAX_IMAGE_FILE_BYTES) return true; + toast.error(`${file.name} is larger than 10 MB`); + return false; + }); + const availableSlots = Math.max(0, MAX_UPLOADED_IMAGES - uploadedImages.length); + if (validSizeFiles.length > availableSlots) { + toast.error(`You can upload up to ${MAX_UPLOADED_IMAGES} images`); } + const acceptedFiles = validSizeFiles.slice(0, availableSlots); + if (!acceptedFiles.length) return; + invalidateGeneration(); - const reader = new FileReader(); - reader.onload = (e) => { - setUploadedImage(e.target?.result as string); - // Clear any previously generated image - setGeneratedImage(null); - }; - reader.readAsDataURL(file); + const newImages = acceptedFiles.map((file) => { + const id = `upload-${uploadIdRef.current++}`; + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve({ + id, + url: reader.result as string, + title: file.name, + source: "upload", + }); + reader.onerror = () => reject(new Error(`Could not read ${file.name}`)); + reader.readAsDataURL(file); + }); + }); + + Promise.allSettled(newImages) + .then((results) => { + const images = results + .filter((result): result is PromiseFulfilledResult => result.status === "fulfilled") + .map((result) => result.value); + if (images.length) { + setUploadedImages((current) => [...current, ...images]); + setSelectedImages((current) => [...current, ...images]); + } + if (images.length !== results.length) toast.error("One or more images could not be read"); + }); + + event.target.value = ""; }; - const clearUploadedImage = () => { - setUploadedImage(null); - setGeneratedImage(null); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } + const toggleImageSelection = (image: PatternImage) => { + invalidateGeneration(); + setSelectedImages((current) => { + const isSelected = current.some((selected) => selected.id === image.id); + return isSelected + ? current.filter((selected) => selected.id !== image.id) + : [...current, image]; + }); + }; + + const removeUploadedImage = (id: string) => { + invalidateGeneration(); + setUploadedImages((current) => current.filter((image) => image.id !== id)); + setSelectedImages((current) => current.filter((image) => image.id !== id)); + }; + + const clearAllUploads = () => { + invalidateGeneration(); + const uploadIds = new Set(uploadedImages.map((image) => image.id)); + setUploadedImages([]); + setSelectedImages((current) => current.filter((image) => !uploadIds.has(image.id))); }; const generatePattern = ( ctx: CanvasRenderingContext2D, - img: HTMLImageElement, + images: Array, canvasWidth: number, canvasHeight: number, imgSpacing: number, imgOpacity: number, imgScale: number, + type: PatternType, + seed: number, + rotationDegrees: number, + horizontalOffset = 0, + motionPadding = 0, ) => { ctx.clearRect(0, 0, canvasWidth, canvasHeight); - // Set background color if (!isTransparent) { ctx.fillStyle = color; ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - // Calculate image size while maintaining aspect ratio - const aspectRatio = img.width / img.height; - const patternHeight = 100 * (imgScale / 100); // Base pattern size scaled - const patternWidth = patternHeight * aspectRatio; - - // Calculate spacing + const random = createRandom(seed); const spacingPixels = imgSpacing; + const baseHeight = Math.max(12, 100 * (imgScale / 100)); + const sizes = images.map((source) => { + const aspectRatio = source.image.width / source.image.height || 1; + return { width: baseHeight * aspectRatio, height: baseHeight }; + }); + ctx.save(); + ctx.translate(horizontalOffset, 0); - // Set opacity - ctx.globalAlpha = imgOpacity / 100; + const drawTile = (sourceIndex: number, x: number, y: number, width: number, height: number, tileRotation = 0) => { + const source = images[sourceIndex % images.length]; + ctx.save(); + ctx.globalAlpha = imgOpacity / 100; + ctx.translate(x + width / 2, y + height / 2); + ctx.rotate((rotationDegrees * Math.PI) / 180 + tileRotation); + ctx.drawImage(source.image, -width / 2, -height / 2, width, height); + ctx.restore(); + }; + + const maxWidth = Math.max(...sizes.map(({ width }) => width)); + const areaScale = Math.sqrt((canvasWidth * canvasHeight) / (1920 * 1080)); + const minimumStep = Math.max(12, areaScale * 12); + const horizontalStep = Math.max(minimumStep, maxWidth + spacingPixels); + const verticalStep = Math.max(minimumStep, baseHeight + spacingPixels); + const randomOrder = images.map((_, index) => index); + for (let index = randomOrder.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [randomOrder[index], randomOrder[swapIndex]] = [randomOrder[swapIndex], randomOrder[index]]; + } + let tileIndex = 0; + + const drawStructuredTile = (sourceIndex: number, x: number, y: number) => { + const { width, height } = sizes[sourceIndex]; + const rotationRadians = (rotationDegrees * Math.PI) / 180; + const rotatedWidth = Math.abs(width * Math.cos(rotationRadians)) + Math.abs(height * Math.sin(rotationRadians)); + const rotatedHeight = Math.abs(width * Math.sin(rotationRadians)) + Math.abs(height * Math.cos(rotationRadians)); + const fit = Math.min(1, maxWidth / rotatedWidth, baseHeight / rotatedHeight); + const drawWidth = width * fit; + const drawHeight = height * fit; + drawTile( + sourceIndex, + x + (maxWidth - drawWidth) / 2, + y + (baseHeight - drawHeight) / 2, + drawWidth, + drawHeight, + ); + }; - // Draw pattern - for (let y = 0; y < canvasHeight; y += patternHeight + spacingPixels) { - for (let x = 0; x < canvasWidth; x += patternWidth + spacingPixels) { - ctx.drawImage(img, x, y, patternWidth, patternHeight); + if (type === "random") { + for (let y = -baseHeight - motionPadding; y < canvasHeight + baseHeight + motionPadding; y += verticalStep) { + for (let x = -maxWidth - motionPadding; x < canvasWidth + maxWidth + motionPadding; x += horizontalStep) { + const sourceIndex = randomOrder[tileIndex % randomOrder.length]; + const { width, height } = sizes[sourceIndex]; + drawTile(sourceIndex, x, y, width, height); + tileIndex += 1; + } } + ctx.restore(); + return; } - // Reset opacity - ctx.globalAlpha = 1; + if (type === "scattered") { + for (let row = 0, y = -baseHeight - motionPadding; y < canvasHeight + baseHeight + motionPadding; row += 1, y += verticalStep) { + const rowOffset = (row % 2) * horizontalStep * 0.25; + for (let x = -maxWidth - motionPadding + rowOffset; x < canvasWidth + maxWidth + motionPadding; x += horizontalStep) { + const sourceIndex = randomOrder[tileIndex % randomOrder.length]; + drawStructuredTile(sourceIndex, x, y); + tileIndex += 1; + } + } + ctx.restore(); + return; + } + + for (let row = 0, y = -baseHeight - motionPadding; y < canvasHeight + baseHeight + motionPadding; row += 1, y += verticalStep) { + const rowOffset = type === "staggered" + ? (row % 2) * horizontalStep / 2 + : type === "diagonal" + ? (row * horizontalStep * 0.35) % horizontalStep + : 0; + + for (let x = -maxWidth - motionPadding + rowOffset; x < canvasWidth + maxWidth + motionPadding; x += horizontalStep) { + const sourceIndex = randomOrder[tileIndex % randomOrder.length]; + drawStructuredTile(sourceIndex, x, y); + tileIndex += 1; + } + } + ctx.restore(); + }; + + const loadSelectedImages = () => Promise.all(selectedImages.map((source) => new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "Anonymous"; + image.onload = () => resolve({ ...source, image }); + image.onerror = () => reject(new Error(`Failed to load ${source.title}`)); + image.src = source.url; + }))); + + const canvasToObjectUrl = (canvas: HTMLCanvasElement): Promise => new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("The generated image could not be encoded")); + return; + } + resolve(URL.createObjectURL(blob)); + }, "image/png"); + }); + + const handleGenerate = async () => { + if (!selectedImages.length || isRecording) return; + + const generationId = ++generationIdRef.current; + setIsGenerating(true); + const [width, height] = size.split("x").map((dim) => parseInt(dim, 10)); + + try { + const loadedImages = await loadSelectedImages(); + + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + generatePattern(ctx, loadedImages, width, height, spacing[0], opacity[0], scale[0], patternType, randomSeed, rotation[0]); + if (generationId === generationIdRef.current) { + const imageUrl = await canvasToObjectUrl(canvas); + if (generationId === generationIdRef.current) { + setGeneratedImage((current) => { + if (current) URL.revokeObjectURL(current); + return imageUrl; + }); + } else { + URL.revokeObjectURL(imageUrl); + } + } + } catch (error) { + console.error("Failed to load image for generation", error); + toast.error("One or more selected images could not be loaded"); + } finally { + if (generationId === generationIdRef.current) { + setIsGenerating(false); + } + } }; - // Debounced generation effect useEffect(() => { - const sourceImage = selectedTexture || uploadedImage; - if (!sourceImage) return; + if (!selectedImages.length || isRecording) return; const timer = setTimeout(() => { handleGenerate(); - }, 500); // 500ms debounce + }, 500); return () => clearTimeout(timer); // Generation is intentionally debounced from these controls. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [color, size, spacing[0], opacity[0], scale[0], uploadedImage, selectedTexture, isTransparent]); - - const handleGenerate = () => { - const sourceImage = selectedTexture || uploadedImage; - if (!sourceImage) return; + }, [color, size, spacingValue, opacityValue, scaleValue, selectedImages, patternType, isTransparent, randomSeed, rotationValue, isRecording]); - setIsGenerating(true); - - // Create dimensions from size string - const [width, height] = size.split("x").map((dim) => parseInt(dim, 10)); + useEffect(() => { + setVideoUrl((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + }, [color, size, spacingValue, opacityValue, scaleValue, patternType, isTransparent, rotationValue, randomSeed, animationDurationValue, animationFpsValue, animationDistanceValue, selectedImages]); - // Create an image element from uploaded image - const img = new Image(); - img.crossOrigin = "Anonymous"; // Enable CORS for canvas - img.onload = () => { - // Get canvas - const canvas = canvasRef.current; - if (!canvas) return; + const handleCreateVideo = async () => { + if (!selectedImages.length || isRecording) return; + const canvas = canvasRef.current; + if (!canvas || !canvas.captureStream || typeof MediaRecorder === "undefined") { + toast.error("Animated video export is not supported in this browser"); + return; + } - // Set canvas dimensions + setIsRecording(true); + setRecordingProgress(0); + try { + const loadedImages = await loadSelectedImages(); + const [width, height] = size.split("x").map((dim) => parseInt(dim, 10)); canvas.width = width; canvas.height = height; - - // Get context const ctx = canvas.getContext("2d"); - if (!ctx) return; + if (!ctx) throw new Error("Canvas is unavailable"); - // Generate pattern - generatePattern(ctx, img, width, height, spacing[0], opacity[0], scale[0]); + const patternCanvas = document.createElement("canvas"); + patternCanvas.width = width + animationDistance[0]; + patternCanvas.height = height; + const patternContext = patternCanvas.getContext("2d"); + if (!patternContext) throw new Error("Pattern canvas is unavailable"); + generatePattern( + patternContext, + loadedImages, + patternCanvas.width, + height, + spacing[0], + opacity[0], + scale[0], + patternType, + randomSeed, + rotation[0], + 0, + 400, + ); - // Convert canvas to data URL - const dataUrl = canvas.toDataURL("image/png"); - setGeneratedImage(dataUrl); - setIsGenerating(false); - }; + const stream = canvas.captureStream(animationFps[0]); + const videoTrack = stream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + const supportsManualFrameCapture = Boolean(videoTrack && typeof videoTrack.requestFrame === "function"); + const mimeType = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"] + .find((candidate) => MediaRecorder.isTypeSupported(candidate)); + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + const chunks: Blob[] = []; + recorder.ondataavailable = (event) => { + if (event.data.size) chunks.push(event.data); + }; - img.onerror = () => { - setIsGenerating(false); - // Only toast on error if it's not just a transition state - console.error("Failed to load image for generation"); - }; + let rejectRecording: (reason?: unknown) => void = () => undefined; + const recordingPromise = new Promise((resolve, reject) => { + rejectRecording = reject; + recorder.onstop = () => resolve(new Blob(chunks, { type: mimeType || "video/webm" })); + recorder.onerror = (event) => { + if (recordingTimerRef.current) { + clearTimeout(recordingTimerRef.current); + recordingTimerRef.current = null; + } + stream.getTracks().forEach((track) => track.stop()); + reject((event as ErrorEvent).error || new Error("Recording failed")); + }; + }); + const durationMs = animationDuration[0] * 1000; + const frameInterval = 1000 / animationFps[0]; + const totalFrames = Math.max(1, Math.round(animationDuration[0] * animationFps[0])); + let frameIndex = 0; + try { + recorder.start(); + } catch (error) { + rejectRecording(error); + if (recordingTimerRef.current) { + clearTimeout(recordingTimerRef.current); + recordingTimerRef.current = null; + } + stream.getTracks().forEach((track) => track.stop()); + throw error; + } + const startedAt = performance.now(); - img.src = sourceImage; + const renderFrame = () => { + const progress = totalFrames === 1 ? 1 : frameIndex / (totalFrames - 1); + const sourceX = animationDistance[0] * progress; + ctx.clearRect(0, 0, width, height); + ctx.drawImage(patternCanvas, sourceX, 0, width, height, 0, 0, width, height); + if (supportsManualFrameCapture) videoTrack.requestFrame(); + setRecordingProgress(progress * 100); + frameIndex += 1; + if (frameIndex < totalFrames) { + const nextFrameAt = startedAt + frameIndex * frameInterval; + recordingTimerRef.current = setTimeout(renderFrame, Math.max(0, nextFrameAt - performance.now())); + } else { + recordingTimerRef.current = setTimeout(() => { + recorder.stop(); + stream.getTracks().forEach((track) => track.stop()); + }, Math.max(0, durationMs - (performance.now() - startedAt))); + } + }; + renderFrame(); + const blob = await recordingPromise; + setVideoUrl((current) => { + if (current) URL.revokeObjectURL(current); + return URL.createObjectURL(blob); + }); + toast.success("Animated background is ready to download"); + } catch (error) { + console.error("Failed to create animated background", error); + toast.error("Could not create the animated background"); + } finally { + if (recordingTimerRef.current) { + clearTimeout(recordingTimerRef.current); + recordingTimerRef.current = null; + } + setIsRecording(false); + } }; const handleDownload = () => { @@ -208,6 +585,25 @@ const BackgroundGenerator = () => { document.body.removeChild(link); }; + const handleVideoDownload = () => { + if (!videoUrl) return; + const link = document.createElement("a"); + link.href = videoUrl; + link.download = `minecraft-animated-background-${size}.webm`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + useEffect(() => () => { + if (recordingTimerRef.current) clearTimeout(recordingTimerRef.current); + if (videoUrl) URL.revokeObjectURL(videoUrl); + }, [videoUrl]); + + useEffect(() => () => { + if (generatedImage) URL.revokeObjectURL(generatedImage); + }, [generatedImage]); + return (
    @@ -251,16 +647,39 @@ const BackgroundGenerator = () => { Background Generator -

    - Generate custom Minecraft-themed backgrounds for your content. - Perfect for thumbnails, stream overlays, and channel art. -

    +

    + Generate custom Minecraft-themed backgrounds for your content. + Perfect for thumbnails, stream overlays, and channel art. +

    + + { + const nextMode = value as OutputMode; + setOutputMode(nextMode); + if (nextMode === "video" && (size === "5120x2880" || size === "7680x4320")) { + setSize("3840x2160"); + } + }} + className="mb-8" + > + + + + Image + + + + Video (WebM) + + + -
    -
    +
    +
    - + Library @@ -268,6 +687,27 @@ const BackgroundGenerator = () => { +
    + + setTextureSearch(event.target.value)} + placeholder="Search textures..." + aria-label="Search textures" + className="h-8 pl-8 pr-8 text-xs pixel-corners" + /> + {textureSearch && ( + + )} +
    {isLoadingTextures ? ( @@ -277,31 +717,42 @@ const BackgroundGenerator = () => {
    ) : (
    - {textures.slice(0, visibleTexturesCount).map((texture) => ( - - ))} + className="w-full h-full object-contain pixelated" + /> + {selectedImages.some((image) => image.id === `library-${texture.id}`) && ( + โœ“ + )} + + ))}
    )} - {textures.length > visibleTexturesCount && ( + {!isLoadingTextures && !filteredTextures.length && ( +
    + {textureSearch ? `No textures match "${textureSearch}"` : "No textures available"} +
    + )} + {filteredTextures.length > visibleTexturesCount && (
    )} @@ -323,6 +774,7 @@ const BackgroundGenerator = () => { { - {uploadedImage && ( + {uploadedImages.length > 0 && ( )}
    - {uploadedImage && ( -
    - Uploaded + {uploadedImages.length > 0 && ( +
    + {uploadedImages.map((image) => ( +
    selected.id === image.id) ? "border-cow-purple" : "border-transparent"}`}> + + +
    + ))}
    )}
    -
    + +
    +
    +
    +

    Selected images

    +

    Choose from uploads and the library.

    +
    + + {selectedImages.length} + +
    + {selectedImages.length > 0 && ( +
    + {selectedImages.map((image) => ( + + ))} +
    + )} +
    + +
    +
    + + +
    + +
    + +
    +
    +
    + + +
    + {rotation[0]}ยฐ +
    + +
    + 0ยฐ + 90ยฐ + 180ยฐ + 270ยฐ + 360ยฐ +
    +
    + + {outputMode === "video" && ( +
    +
    +

    Animation controls

    +

    Create a repeating right-to-left WebM loop for the selected duration.

    +
    + +
    +
    + + {animationDuration[0]}s +
    + +
    + +
    +
    + + {animationFps[0]} FPS +
    + +
    + +
    +
    + + {animationDistance[0]}px +
    + +

    Textures move smoothly from right to left, then repeat.

    +
    +
    + )} +
    @@ -465,6 +1035,15 @@ const BackgroundGenerator = () => { 3840x2160 (16:9) + + 4096x2160 (DCI 4K) + + {outputMode === "image" && + 5120x2880 (5K) + } + {outputMode === "image" && + 7680x4320 (8K) + } 1080x1080 (1:1) @@ -478,14 +1057,27 @@ const BackgroundGenerator = () => {
    -
    +

    Preview

    -
    - {generatedImage ? ( -
    +
    + {outputMode === "video" && videoUrl ? ( +
    - ) : (uploadedImage || selectedTexture) ? ( + ) : selectedImages.length > 0 ? (

    @@ -508,18 +1100,48 @@ const BackgroundGenerator = () => {

    - Select a texture or upload an image to start + Select one or more images to start

    )} - -
    + +
    - {generatedImage && ( - + {videoUrl && ( + + )} +
    +
    + ) : generatedImage && ( +

    Upload

    - Upload your image and adjust settings + Upload images and adjust settings

    diff --git a/src/pages/BlogView.tsx b/src/pages/BlogView.tsx index 18b6bfd..2b1f93d 100644 --- a/src/pages/BlogView.tsx +++ b/src/pages/BlogView.tsx @@ -136,11 +136,11 @@ export default function BlogView() {
    -
    p]:my-3 [&>p]:leading-7 [&>p:first-child]:mt-0 + [&>h1]:hidden + [&>h2]:mt-8 [&>h2]:mb-3 [&>h2]:text-2xl [&>h2]:font-semibold [&>h2]:font-jetbrains-mono + [&>h3]:mt-6 [&>h3]:mb-2 [&>h3]:text-xl [&>h3]:font-medium [&>h3]:font-jetbrains-mono [&>ul]:my-6 [&>ol]:my-6 [&_li]:mb-2 [&_a]:text-cow-purple [&_a]:underline hover:[&_a]:text-cow-purple/80 [&_pre]:bg-muted/50 [&_pre]:p-4 [&_pre]:rounded-md [&_pre]:pixel-corners diff --git a/src/pages/Community.tsx b/src/pages/Community.tsx index 0297460..3bb6a7d 100644 --- a/src/pages/Community.tsx +++ b/src/pages/Community.tsx @@ -416,7 +416,7 @@ const Community = () => {
    -

    +

    {category.name}

    {
    -

    +

    {video.title}

    diff --git a/src/pages/Contact.tsx b/src/pages/Contact.tsx index 8d2729c..0660e68 100644 --- a/src/pages/Contact.tsx +++ b/src/pages/Contact.tsx @@ -149,7 +149,7 @@ const Contact = () => {
    -

    Get In Touch

    +

    Get In Touch

    Have questions, feedback, or just want to say hello? We'd love to hear from you! @@ -201,7 +201,7 @@ const Contact = () => {

    -

    Support Hours

    +

    Support Hours

    Well, we do what we can! We're all volunteers, not benefiting from the project, but if you join our Discord, we'll really @@ -219,7 +219,7 @@ const Contact = () => { {teamMembers.map((member, index) => (

    setActiveCard(index)} onMouseLeave={() => setActiveCard(null)} @@ -232,7 +232,7 @@ const Contact = () => { loading="lazy" />
    -

    {member.name}

    +

    {member.name}

    {member.role}

    diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 459c721..1f99144 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -14,7 +14,7 @@ const fadeInUp = { initial: { opacity: 0, y: 24 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, amount: 0.1 }, - transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } + transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const } }; const Index = () => { diff --git a/src/pages/LooneyResultPage.tsx b/src/pages/LooneyResultPage.tsx new file mode 100644 index 0000000..afb8c0e --- /dev/null +++ b/src/pages/LooneyResultPage.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; +import { IconAlertTriangle, IconArrowLeft, IconLoader2 } from '@tabler/icons-react'; +import Navbar from '@/components/Navbar'; +import Footer from '@/components/Footer'; +import LooneyResultDisplay from '@/components/LooneyResultDisplay'; +import { LooneyJob, LooneyHistoryRecord } from '@/types/looney'; +import { getLooneyJob, streamLooneyJob } from '@/utils/looneyChecker'; +import { loadLooneyHistory, looneyHistoryUpdateEvent, saveLooneyHistoryRecord, updateLooneyHistoryFromJob } from '@/utils/looneyHistory'; + +const recordFromJob = (jobId: string, job: LooneyJob, current?: LooneyHistoryRecord): LooneyHistoryRecord => { + const track = job.result?.request?.track; + const request = job.result?.request; + const spotifyUrl = request && typeof request.spotify_url === 'string' ? request.spotify_url : undefined; + const trackTitle = track && typeof track.title === 'string' ? track.title : track && typeof track.name === 'string' ? track.name : undefined; + return { + jobId, + sourceLabel: current?.sourceLabel || trackTitle || spotifyUrl || `Looney check ${jobId}`, + sourceType: current?.sourceType || (spotifyUrl ? 'spotify' : 'file'), + createdAt: current?.createdAt || new Date().toISOString(), + status: job.status, + result: job.result || current?.result, + error: job.error || job.detail || job.message || current?.error, + progress: current?.progress, + sourceKey: current?.sourceKey, + }; +}; + +const LooneyResultPage = () => { + const { jobId } = useParams(); + const navigate = useNavigate(); + const [record, setRecord] = useState(() => jobId ? loadLooneyHistory().find((item) => item.jobId === jobId) || null : null); + const recordRef = useRef(record); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + recordRef.current = record; + }, [record]); + + useEffect(() => { + if (!jobId) { + setLoading(false); + setNotFound(true); + return; + } + + const localRecord = loadLooneyHistory().find((item) => item.jobId === jobId); + if (localRecord) { + setRecord(localRecord); + setLoading(false); + setNotFound(false); + } + + const controller = new AbortController(); + let cancelled = false; + const loadRemoteRecord = async () => { + if (localRecord) return; + try { + const job = await getLooneyJob(jobId, controller.signal); + if (cancelled) return; + const recovered = recordFromJob(jobId, job); + saveLooneyHistoryRecord(recovered); + setRecord(recovered); + setNotFound(false); + } catch { + if (!cancelled && !controller.signal.aborted) setNotFound(true); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void loadRemoteRecord(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [jobId]); + + useEffect(() => { + const refresh = () => { + if (!jobId) return; + const current = loadLooneyHistory().find((item) => item.jobId === jobId); + if (current) setRecord(current); + }; + window.addEventListener(looneyHistoryUpdateEvent, refresh); + return () => window.removeEventListener(looneyHistoryUpdateEvent, refresh); + }, [jobId]); + + const running = record?.status === 'queued' || record?.status === 'running'; + + useEffect(() => { + if (!jobId || !running) return; + const controller = new AbortController(); + let cancelled = false; + const currentRecord = () => loadLooneyHistory().find((item) => item.jobId === jobId) || recordRef.current!; + const stream = async () => { + try { + const job = await streamLooneyJob(jobId, controller.signal, (progress) => { + const current = currentRecord(); + const updated = { ...current, progress }; + saveLooneyHistoryRecord(updated); + if (!cancelled) setRecord(updated); + }); + const updated = updateLooneyHistoryFromJob(currentRecord(), job); + saveLooneyHistoryRecord(updated); + if (!cancelled) setRecord(updated); + } catch (error) { + if (!cancelled && !controller.signal.aborted) { + const current = currentRecord(); + const updated = { ...current, status: 'failed' as const, error: error instanceof Error ? error.message : 'Unable to resume this check.' }; + saveLooneyHistoryRecord(updated); + setRecord(updated); + } + } + }; + void stream(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [jobId, running]); + + return
    + {record?.sourceLabel || 'Check result'} - Looney + +
    +
    + Back to checks +

    Check result

    A saved Looney research report, ready to review before publishing.

    + {loading ?

    Loading this Looney check...

    + : record?.result ?
    navigate('/gappa')} />
    + : running ?

    Check in progress

    {record.progress || 'Looney is researching this source.'}

    + : notFound ?

    Check not found

    This Looney job is no longer available.

    Return to checks
    + :

    Check failed

    {record?.error || 'Looney could not complete this check.'}

    Return to checks
    } +
    +
    +
    +
    ; +}; + +export default LooneyResultPage; diff --git a/src/pages/MusicCopyright.tsx b/src/pages/MusicCopyright.tsx index 2e519b5..470fd10 100644 --- a/src/pages/MusicCopyright.tsx +++ b/src/pages/MusicCopyright.tsx @@ -1,255 +1,43 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import Navbar from '@/components/Navbar'; import Footer from '@/components/Footer'; -import { IconMusic, IconSearch, IconAlertCircle, IconRefresh, IconInfoCircle, IconBrandYoutube } from '@tabler/icons-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { toast } from 'sonner'; -import { checkCopyrightStatus, extractYouTubeID } from '@/utils/copyrightChecker'; -import { CopyrightResult } from '@/types/copyright'; -import ResultsDisplay from '@/components/ResultsDisplay'; +import LooneyCheckForm from '@/components/LooneyCheckForm'; +import LooneyHistorySection from '@/components/LooneyHistorySection'; +import LooneyRunningCheckDialog from '@/components/LooneyRunningCheckDialog'; +import { IconFileMusic } from '@tabler/icons-react'; import { Helmet } from 'react-helmet-async'; -import { useAuth } from '@/hooks/useAuth'; -import AuthDialog from '@/components/auth/AuthDialog'; -import ResultsDisplaySkeleton from '@/components/skeletons/ResultsDisplaySkeleton'; +import { LooneyHistoryRecord } from '@/types/looney'; const MusicCopyright = () => { - const [activeTab, setActiveTab] = useState('song'); - const [songArtist, setSongArtist] = useState(''); - const [songTitle, setSongTitle] = useState(''); - const [youtubeUrl, setYoutubeUrl] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [result, setResult] = useState(null); - const [searchAttempted, setSearchAttempted] = useState(false); - const { user, loading } = useAuth(); - const [authDialogOpen, setAuthDialogOpen] = useState(false); - const RATE_LIMIT = 6; - const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour - const LOCALSTORAGE_KEY = 'gappa-checks'; - - function getRecentChecks() { - const raw = localStorage.getItem(LOCALSTORAGE_KEY); - if (!raw) return []; - try { - const arr = JSON.parse(raw); - if (!Array.isArray(arr)) return []; - // Only keep timestamps within the last hour - const now = Date.now(); - return arr.filter((ts) => now - ts < RATE_LIMIT_WINDOW_MS); - } catch { - return []; - } - } - - function logCheck() { - const now = Date.now(); - const arr = getRecentChecks(); - arr.push(now); - localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(arr)); - } - - const handleReset = () => { - setResult(null); - setSearchAttempted(false); - setSongArtist(''); - setSongTitle(''); - setYoutubeUrl(''); - }; - - const handleSearch = async () => { - setSearchAttempted(true); - // Rate limit check - const recentChecks = getRecentChecks(); - if (recentChecks.length >= RATE_LIMIT) { - toast.error('Rate limit reached', { - description: `You can only check 6 songs per hour per device. Please try again later.`, - }); - return; - } - let query; - if (activeTab === 'song') { - if (!songArtist.trim() || !songTitle.trim()) { - toast.error('Please enter both artist and title.'); - return; - } - query = { artist: songArtist, title: songTitle }; - } else { - if (!youtubeUrl.trim() || !extractYouTubeID(youtubeUrl)) { - toast.error('Please enter a valid YouTube URL.'); - return; - } - query = { youtube_url: youtubeUrl }; - } - - setIsLoading(true); - setResult(null); - - try { - const copyrightData = await checkCopyrightStatus(query); - setResult(copyrightData); - logCheck(); // Log the check only if the request was made - - if (copyrightData.error) { - toast.error('Error checking copyright', { - description: copyrightData.error, - }); - } else { - toast.success('Analysis complete'); - } - } catch (error) { - console.error('Error:', error); - toast.error('Failed to process request', { - description: 'An unexpected error occurred. Please try again.', - }); - } finally { - setIsLoading(false); - } - }; - - // Show AuthDialog if not logged in and not loading - if (!loading && !user) { - return ( -
    - - Music Copyright Checker - Renderdragon - - - - - - - - - - -
    -
    -

    - Music Copyright Checker -

    -

    You must be logged in to use the Music Copyright Checker.

    - - -
    -
    -
    -
    - ); - } - - return ( -
    - - Music Copyright Checker - Renderdragon - - - - - - - - - - - -
    -
    -
    -

    - Music Copyright Checker -

    - -

    - Check if a song is safe to use in your videos without copyright issues. - Avoid strikes and claim problems before uploading your content. -

    - - - - Disclaimer - - This tool provides general information and is not a guarantee against copyright claims. - Copyright policies may change over time, and different regions may have different rules. - - - - - - Song - YouTube URL - - -
    - setSongArtist(e.target.value)} - className="pixel-corners" - /> - setSongTitle(e.target.value)} - className="pixel-corners" - /> -
    -
    - -
    - setYoutubeUrl(e.target.value)} - className="pixel-corners" - /> -
    -
    -
    - -
    - -
    - - {isLoading && } - - {!isLoading && result && ( - - )} - - {!isLoading && !result && searchAttempted && ( -
    - -

    No results found

    -

    Try a different search term or check the spelling

    -
    - )} -
    + const [activeJobId, setActiveJobId] = useState(null); + const [existingJob, setExistingJob] = useState(null); + const [limitMessage, setLimitMessage] = useState(false); + const navigate = useNavigate(); + + const showRecord = (record: LooneyHistoryRecord) => { if (record.result) navigate(`/gappa/check/${encodeURIComponent(record.jobId)}`); }; + + return
    + Looney Checks - Renderdragon + +
    +
    +

    Looney Checks

    Research music licensing signals before you publish. Check a Spotify track, catalog music, or an audio file you upload yourself.

    + +
    +
    +

    Start a check

    Choose the source you want Looney to inspect.

    +
    jobId && navigate(`/gappa/check/${encodeURIComponent(jobId)}`)} onExistingJob={setExistingJob} onLimitReached={() => setLimitMessage(true)} onCheckStart={() => setLimitMessage(false)} />
    +
    +
    -
    - -
    -
    - ); + + {limitMessage &&
    Two checks are already running. Wait for one to finish before starting another.
    } +
    +
    +
    + setExistingJob(null)} /> +
    ; }; -export default MusicCopyright; \ No newline at end of file +export default MusicCopyright; diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 97b1a1b..9d75403 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -48,7 +48,7 @@ const NotFound = () => {
    -
    +
    { className="text-cow-purple mb-4 relative" variants={itemVariants} > - { const [selectedMoods, setSelectedMoods] = useState([]); const [mobileMoodFilterOpen, setMobileMoodFilterOpen] = useState(false); const [musicView, setMusicView] = useState<'community' | 'minecraft'>('community'); + const [copyrightResource, setCopyrightResource] = useState(null); const { resources, @@ -207,6 +209,10 @@ const ResourcesHub = () => { } }; + const onCheckCopyright = useCallback((resource: Resource) => { + setCopyrightResource(resource); + }, []); + const renderContent = () => ( <> { onSortOrderChange={handleSortOrderChange} isMobile={isMobile} inputRef={inputRef} - fontPreviewText={fontPreviewText} - onFontPreviewTextChange={setFontPreviewText} /> {(selectedCategory === 'minecraft-icons' || selectedCategory === 'mcsounds') && ( @@ -336,7 +340,7 @@ const ResourcesHub = () => { )} {isMinecraftMusicView ? ( - { onSelectResource={setSelectedResource} onClearFilters={handleClearSearchWrapped} hasCategoryResources={minecraftMusic.resources.length > 0} - fontPreviewText={fontPreviewText} - /> + onCheckCopyright={onCheckCopyright} + /> ) : ( - { onSelectResource={setSelectedResource} onClearFilters={handleClearSearch} hasCategoryResources={hasCategoryResources} - fontPreviewText={fontPreviewText} - /> + onCheckCopyright={onCheckCopyright} + /> )} ); @@ -573,6 +577,11 @@ const ResourcesHub = () => { onOpenChange={setAuthDialogOpen} /> + setCopyrightResource(null)} + /> + diff --git a/src/types/copyright.ts b/src/types/copyright.ts deleted file mode 100644 index c2d81d9..0000000 --- a/src/types/copyright.ts +++ /dev/null @@ -1,95 +0,0 @@ - -export interface VideoInfo { - title: string; - channel: string; - license: string; - is_copyrighted: boolean; - description: string; - thumbnail: string | null; - duration: string; - view_count: number; - upload_date: string; - url: string; -} - -export interface SpotifyTrackInfo { - title: string; - artist: string; - album: string; - release_date: string; - spotify_url: string; - thumbnail: string | null; - is_copyrighted: boolean; - copyright_text: string; -} - -export interface ChannelInfo { - title: string; - description: string; - subscribers: string; - views: string; - videos: string; - watch_hours: number; - created_at: string; - profile_pic: string; - banner_url: string | null; -} - -export interface YouTubeSearchResult { - title: string; - video_id: string; - published_at?: string; -} - -export interface CopyrightResult { - status: string; - title: string; - artist: string; - confidence: number; - riskAssessment: string; - recommendedAction: string; - platforms: { - youtube: string; - twitch: string; - }; - sources: { - contentId: string; - pro: string; - drm: string; - publicDomain: string; - royaltyFree: string; - commercialDatabases: string; - openSources: string; - }; - sourceAnalysis: { - commercialPresence: boolean; - openSourcePresence: boolean; - totalMatches: number; - }; - processingTime: string; - sourcesChecked: string[]; - sourceStats: { - total: number; - successful: number; - coverage: number; - }; - lastUpdated: string; - apiVersion: string; - imageUrl?: string; - riskFactors?: { - commercial: number; - popularity: number; - official: number; - label: number; - distribution: number; - }; - totalRiskScore?: number; - youtubeAnalysis?: { - totalVideos: number; - officialContent: number; - userGeneratedContent: number; - userContentRatio: number; - assessment: string; - }; - error?: string; -} diff --git a/src/types/looney.ts b/src/types/looney.ts new file mode 100644 index 0000000..7bc18cb --- /dev/null +++ b/src/types/looney.ts @@ -0,0 +1,45 @@ +export type LooneyValue = string | number | boolean | null | LooneyValue[] | { [key: string]: LooneyValue }; + +export type LooneyJobStatus = 'queued' | 'running' | 'complete' | 'failed'; + +export interface LooneyJob { + job_id: string; + status: LooneyJobStatus; + result?: LooneyResult; + error?: string; + detail?: string; + message?: string; + status_url?: string; +} + +export interface LooneyHistoryRecord { + jobId: string; + sourceLabel: string; + sourceType: 'file' | 'spotify'; + createdAt: string; + status: LooneyJobStatus; + result?: LooneyResult; + error?: string; + progress?: string; + sourceKey?: string; +} + +export interface LooneyResult { + request?: { + track?: { [key: string]: LooneyValue }; + credits?: LooneyValue; + [key: string]: LooneyValue | undefined; + }; + research?: { + status?: string; + summary?: string; + matches?: LooneyValue; + sources?: LooneyValue; + usage_assessment?: LooneyValue; + official_licensing_contacts?: LooneyValue; + warnings?: LooneyValue; + [key: string]: LooneyValue | undefined; + }; + ai_meta?: { [key: string]: LooneyValue }; + [key: string]: LooneyValue | undefined; +} diff --git a/src/utils/copyrightChecker.ts b/src/utils/copyrightChecker.ts deleted file mode 100644 index b03268d..0000000 --- a/src/utils/copyrightChecker.ts +++ /dev/null @@ -1,94 +0,0 @@ - -import { CopyrightResult } from '@/types/copyright'; - -// YouTube URL pattern -export const YOUTUBE_URL_PATTERN = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; - -// Extract YouTube ID from URL -export function extractYouTubeID(url: string): string | null { - const match = url.match(YOUTUBE_URL_PATTERN); - return match && match[1] ? match[1] : null; -} - -// New function to check copyright status that MusicCopyright.tsx needs -export async function checkCopyrightStatus(query: { artist: string; title: string } | { youtube_url: string }): Promise { - try { - // Use the correct API URL - this appears to be an external service - const apiUrl = "https://ltazpjoqbhtqxqvrvnka.supabase.co/functions/v1/check"; - const apiKey = import.meta.env.VITE_GAPPA_API_KEY || ""; - const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; - - const body = query; - - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - 'Authorization': `Bearer ${anonKey}` - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); - } - - const data: CopyrightResult = await response.json(); - return data; - } catch (error) { - console.error('Error checking copyright:', error); - return { - status: 'unknown' as const, - title: 'Unknown', - artist: 'Unknown', - confidence: 0, - riskAssessment: 'Unknown', - recommendedAction: 'Unable to process request', - platforms: { - youtube: 'Unable to check', - twitch: 'Unable to check' - }, - sources: { - contentId: 'Unable to check', - pro: 'Unable to check', - drm: 'Unable to check', - publicDomain: 'Unable to check', - royaltyFree: 'Unknown', - commercialDatabases: 'Unknown', - openSources: 'Unknown' - }, - sourceAnalysis: { - commercialPresence: false, - openSourcePresence: false, - totalMatches: 0 - }, - youtubeAnalysis: { - totalVideos: 0, - officialContent: 0, - userGeneratedContent: 0, - userContentRatio: 0, - assessment: 'Unable to analyze' - }, - riskFactors: { - commercial: 0, - popularity: 0, - official: 0, - label: 0, - distribution: 0 - }, - totalRiskScore: 0, - processingTime: 'N/A', - sourcesChecked: [], - sourceStats: { - total: 0, - successful: 0, - coverage: 0 - }, - lastUpdated: new Date().toISOString(), - apiVersion: '1.0', - imageUrl: undefined, - error: error instanceof Error ? error.message : 'Unknown error' - }; - } -} diff --git a/src/utils/looneyChecker.ts b/src/utils/looneyChecker.ts new file mode 100644 index 0000000..131fff6 --- /dev/null +++ b/src/utils/looneyChecker.ts @@ -0,0 +1,243 @@ +import { LooneyJob, LooneyResult } from '@/types/looney'; +import { loadLooneyHistory, MAX_RUNNING_LOONEY_AGE_MS, saveLooneyHistoryRecord, updateLooneyHistoryFromJob } from '@/utils/looneyHistory'; +import { supabase } from '@/integrations/supabase/client'; + +export const MAX_LOONEY_FILE_BYTES = 50 * 1024 * 1024; +const JOB_RECOVERY_INTERVAL_MS = 2000; +const MAX_JOB_RECOVERY_ATTEMPTS = 150; +const BROWSER_RATE_LIMIT_KEY = 'renderdragon-looney-browser-id'; + +function createBrowserRateLimitId(): string { + const webCrypto = typeof globalThis.crypto !== 'undefined' ? globalThis.crypto : undefined; + if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID(); + + if (typeof webCrypto?.getRandomValues === 'function') { + const bytes = webCrypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} + +function getBrowserRateLimitId(): string { + const existing = localStorage.getItem(BROWSER_RATE_LIMIT_KEY); + if (existing) return existing; + const created = createBrowserRateLimitId(); + localStorage.setItem(BROWSER_RATE_LIMIT_KEY, created); + return created; +} + +async function getLooneyRequestHeaders(contentType?: string): Promise> { + const headers: Record = { 'X-Looney-Browser-ID': getBrowserRateLimitId() }; + if (contentType) headers['Content-Type'] = contentType; + const { data } = await supabase.auth.getSession(); + if (data.session?.access_token) headers.Authorization = `Bearer ${data.session.access_token}`; + return headers; +} + +export async function refreshRunningLooneyChecks(): Promise { + const running = loadLooneyHistory().filter((record) => record.status === 'queued' || record.status === 'running'); + await Promise.all(running.map(async (record) => { + const createdAt = Date.parse(record.createdAt); + if (!Number.isNaN(createdAt) && Date.now() - createdAt >= MAX_RUNNING_LOONEY_AGE_MS) { + saveLooneyHistoryRecord({ ...record, status: 'failed', error: 'This check expired after the Looney service timeout.' }); + return; + } + try { + const job = await getLooneyJob(record.jobId); + saveLooneyHistoryRecord(updateLooneyHistoryFromJob(record, job)); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (/404|not found|unknown job|invalid job/i.test(message)) { + saveLooneyHistoryRecord({ ...record, status: 'failed', error: 'This Looney job is no longer available.' }); + } + // A temporary network failure should not falsely free a running slot. + } + })); +} + +export interface LooneyCheckInput { + fileUrl?: string; + spotifyUrl?: string; +} + +export interface LooneyCheckCallbacks { + onJobCreated?: (job: LooneyJob) => void; + onJobUpdate?: (job: LooneyJob) => void; + onProgress?: (message: string) => void; +} + +async function readJsonResponse(response: Response): Promise> { + return response.json().catch(() => ({})); +} + +export async function startLooneyJob( + { fileUrl, spotifyUrl }: LooneyCheckInput, + signal?: AbortSignal, +): Promise { + if (!fileUrl && !spotifyUrl) { + throw new Error('Choose an audio file or enter a Spotify track URL.'); + } + + const options: RequestInit = { + method: 'POST', + signal, + headers: await getLooneyRequestHeaders('application/json'), + body: JSON.stringify(fileUrl ? { file_url: fileUrl } : { spotify_url: spotifyUrl }), + }; + + const response = await fetch('/api/looney-check', options); + const data = await readJsonResponse(response); + if (!response.ok) { + const message = data.error || data.detail || data.message || data.title; + if (response.status === 429) { + const retryAfter = Number(data.retry_after_seconds || response.headers.get('Retry-After') || 0); + throw new Error(String(message || `Daily limit reached. Try again in ${Math.ceil(retryAfter / 3600)} hours.`)); + } + throw new Error(String(message || `Looney could not process this track (${response.status}).`)); + } + + if (!data.job_id) { + throw new Error('Looney did not return a job ID.'); + } + + return data as unknown as LooneyJob; +} + +export async function getLooneyJob(jobId: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/looney-check?job_id=${encodeURIComponent(jobId)}`, { signal }); + const job = await readJsonResponse(response); + if (!response.ok) { + const message = job.error || job.detail || job.message; + throw new Error(String(message || `Unable to read the Looney job (${response.status}).`)); + } + return job as unknown as LooneyJob; +} + +async function waitForTerminalJob( + jobId: string, + signal?: AbortSignal, + onProgress?: (message: string) => void, +): Promise { + for (let attempt = 0; attempt < MAX_JOB_RECOVERY_ATTEMPTS; attempt += 1) { + try { + const job = await getLooneyJob(jobId, signal); + if (job.status === 'complete' || job.status === 'failed' || job.result) return job; + onProgress?.(job.status === 'queued' ? 'Your check is queued...' : 'Looney is still researching...'); + } catch (error) { + if (signal?.aborted) throw error; + onProgress?.('Connection interrupted. Reconnecting to Looney...'); + } + await new Promise((resolve) => setTimeout(resolve, JOB_RECOVERY_INTERVAL_MS)); + } + + throw new Error('Looney took too long to finish this check.'); +} + +export async function streamLooneyJob( + jobId: string, + signal?: AbortSignal, + onProgress?: (message: string) => void, +): Promise { + const response = await fetch(`/api/looney-check?job_id=${encodeURIComponent(jobId)}&stream=1`, { signal }); + if (!response.ok) { + const error = await readJsonResponse(response); + const message = error.error || error.detail || error.message; + throw new Error(String(message || `Unable to stream the Looney job (${response.status}).`)); + } + + if (!response.body) return getLooneyJob(jobId, signal); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let eventName = 'message'; + let eventData: string[] = []; + let streamJob: LooneyJob | null = null; + + const processEvent = () => { + if (eventData.length === 0) { + eventName = 'message'; + return; + } + const rawData = eventData.join('\n'); + let payload: Record; + try { + payload = JSON.parse(rawData) as Record; + } catch { + payload = { message: rawData }; + } + + const message = payload.message || payload.stage; + if (eventName === 'progress' && message) onProgress?.(String(message)); + + if (eventName === 'complete' || payload.status === 'complete') { + streamJob = { ...payload, job_id: String(payload.job_id || jobId), status: 'complete' } as LooneyJob; + } else if (eventName === 'failed' || payload.status === 'failed') { + streamJob = { ...payload, job_id: String(payload.job_id || jobId), status: 'failed' } as LooneyJob; + } + + eventName = 'message'; + eventData = []; + }; + + try { + while (!streamJob) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line) { + processEvent(); + } else if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + eventData.push(line.slice(5).trim()); + } + } + + if (done) { + if (buffer) eventData.push(buffer.startsWith('data:') ? buffer.slice(5).trim() : buffer); + processEvent(); + break; + } + } + } catch (streamError) { + if (signal?.aborted) throw streamError; + return waitForTerminalJob(jobId, signal, onProgress); + } finally { + await reader.cancel().catch(() => undefined); + } + + if (streamJob?.status === 'complete' && !streamJob.result) return waitForTerminalJob(jobId, signal, onProgress); + if (streamJob) return streamJob; + return waitForTerminalJob(jobId, signal, onProgress); +} + +export async function checkWithLooney( + input: LooneyCheckInput, + signal?: AbortSignal, + callbacks?: LooneyCheckCallbacks, +): Promise { + const data = await startLooneyJob(input, signal); + callbacks?.onJobCreated?.(data); + callbacks?.onJobUpdate?.(data); + const job = await streamLooneyJob(data.job_id, signal, callbacks?.onProgress); + callbacks?.onJobUpdate?.(job); + + if (job.status === 'failed') { + const message = job.error || job.detail || job.message || 'Looney could not complete this check.'; + throw new Error(String(message)); + } + + if (job.status !== 'complete') { + throw new Error('Looney closed the progress stream before returning a result.'); + } + + return (job.result || job) as LooneyResult; +} diff --git a/src/utils/looneyHistory.ts b/src/utils/looneyHistory.ts new file mode 100644 index 0000000..31378ca --- /dev/null +++ b/src/utils/looneyHistory.ts @@ -0,0 +1,75 @@ +import { LooneyHistoryRecord, LooneyJob } from '@/types/looney'; + +const STORAGE_KEY = 'renderdragon-looney-history'; +const MAX_HISTORY_RECORDS = 12; +const UPDATE_EVENT = 'looney-history-updated'; + +export const MAX_RUNNING_LOONEY_CHECKS = 2; +export const MAX_RUNNING_LOONEY_AGE_MS = 10 * 60 * 1000; + +const isFreshRunningRecord = (record: LooneyHistoryRecord) => { + if (record.status !== 'queued' && record.status !== 'running') return false; + const createdAt = Date.parse(record.createdAt); + return Number.isNaN(createdAt) || Date.now() - createdAt < MAX_RUNNING_LOONEY_AGE_MS; +}; + +export function findRunningLooneyCheck(sourceKey: string): LooneyHistoryRecord | undefined { + return loadLooneyHistory().find((record) => { + if (!isFreshRunningRecord(record)) return false; + const legacySpotifyKey = record.sourceType === 'spotify' ? `spotify:${(record.sourceLabel.match(/track\/([a-zA-Z0-9]+)/)?.[1] || record.sourceLabel).toLowerCase()}` : ''; + return record.sourceKey === sourceKey || (!record.sourceKey && (sourceKey === `file:${record.sourceLabel.toLowerCase().trim()}` || sourceKey === legacySpotifyKey)); + }); +} + +export function countRunningLooneyChecks(): number { + return loadLooneyHistory().filter(isFreshRunningRecord).length; +} + +export function loadLooneyHistory(): LooneyHistoryRecord[] { + if (typeof window === 'undefined') return []; + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + return Array.isArray(stored) ? stored : []; + } catch { + return []; + } +} + +export function saveLooneyHistoryRecord(record: LooneyHistoryRecord): void { + if (typeof window === 'undefined') return; + const records = loadLooneyHistory().filter((item) => item.jobId !== record.jobId); + records.unshift(record); + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(records.slice(0, MAX_HISTORY_RECORDS))); + window.dispatchEvent(new Event(UPDATE_EVENT)); + } catch { + // A large result should not prevent the current check from completing. + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(records.slice(0, 5).map(({ result, ...item }) => item))); + window.dispatchEvent(new Event(UPDATE_EVENT)); + } catch { + // Storage may be disabled or full; the active check can still continue. + } + } +} + +export function updateLooneyHistoryFromJob( + current: LooneyHistoryRecord, + job: LooneyJob, +): LooneyHistoryRecord { + return { + ...current, + status: job.status || current.status, + result: job.result || current.result, + error: job.error || job.detail || job.message || current.error, + }; +} + +export function clearLooneyHistory(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(STORAGE_KEY); + window.dispatchEvent(new Event(UPDATE_EVENT)); +} + +export { UPDATE_EVENT as looneyHistoryUpdateEvent }; diff --git a/src/utils/migrateResources.ts b/src/utils/migrateResources.ts index ac0f179..564e74a 100644 --- a/src/utils/migrateResources.ts +++ b/src/utils/migrateResources.ts @@ -11,21 +11,18 @@ interface JsonResource { preview_url?: string; } -interface JsonResourcesData { - music: JsonResource[]; - sfx: JsonResource[]; - images: JsonResource[]; - animations: JsonResource[]; - fonts: JsonResource[]; - presets: JsonResource[]; -} +const isJsonResource = (value: unknown): value is JsonResource => { + if (!value || typeof value !== "object") return false; + const resource = value as Record; + return typeof resource.id === "number" && typeof resource.title === "string"; +}; export const migrateJsonResourcesToSupabase = async () => { try { console.log("Starting migration of JSON resources to Supabase..."); // Fetch the JSON resources (new all-resources format with legacy fallback) - let jsonData: JsonResourcesData | Resource[] | null = null; + let jsonData: unknown = null; const allResponse = await fetch("/resources.all.json"); if (allResponse.ok) { @@ -38,29 +35,42 @@ export const migrateJsonResourcesToSupabase = async () => { jsonData = await legacyResponse.json(); } - const normalizedData: Record = Array.isArray( - jsonData, - ) - ? jsonData.reduce( + if (!jsonData || typeof jsonData !== "object") { + throw new Error("Resource JSON is empty or malformed"); + } + + const sourceData = jsonData as Record | unknown[]; + const normalizedData: Record = Array.isArray(sourceData) + ? sourceData.reduce( (acc, resource) => { - const resourceId = (resource as Resource).id; - if (typeof resourceId !== "number") return acc; - const category = (resource as Resource).category || "uncategorized"; + if (!isJsonResource(resource)) throw new Error("Resource JSON contains a malformed resource"); + const typedResource = resource as Resource; + const category = typedResource.category || "uncategorized"; if (!acc[category]) acc[category] = []; acc[category].push({ - id: resourceId, - title: (resource as Resource).title, - credit: (resource as Resource).credit || undefined, - filetype: (resource as Resource).filetype || undefined, - software: (resource as Resource).software || undefined, - description: (resource as Resource).description || undefined, - preview_url: (resource as Resource).preview_url || undefined, + id: typedResource.id as number, + title: typedResource.title, + credit: typedResource.credit || undefined, + filetype: typedResource.filetype || undefined, + software: typedResource.software || undefined, + description: typedResource.description || undefined, + preview_url: typedResource.preview_url || undefined, }); return acc; }, {} as Record, ) - : (jsonData as JsonResourcesData); + : Object.entries(sourceData).reduce>((acc, [category, resources]) => { + if (!Array.isArray(resources) || !resources.every(isJsonResource)) { + throw new Error(`Resource JSON category "${category}" is malformed`); + } + acc[category] = resources; + return acc; + }, {}); + + if (Object.values(normalizedData).every((resources) => resources.length === 0)) { + throw new Error("Resource JSON contains no resources"); + } console.log("Fetched JSON data:", normalizedData); @@ -111,6 +121,9 @@ export const migrateJsonResourcesToSupabase = async () => { console.log( `Converted ${supabaseResources.length} resources for migration`, ); + if (supabaseResources.length === 0) { + throw new Error("Migration produced no resources; existing data was preserved"); + } // Clear existing resources (optional - comment out if you want to keep existing data) console.log("Clearing existing resources..."); diff --git a/supabase/migrations/20260810000000_looney_check_rate_limits.sql b/supabase/migrations/20260810000000_looney_check_rate_limits.sql new file mode 100644 index 0000000..95f5b0e --- /dev/null +++ b/supabase/migrations/20260810000000_looney_check_rate_limits.sql @@ -0,0 +1,165 @@ +create table if not exists public.looney_check_rate_limits ( + id bigint generated always as identity primary key, + bucket_type text not null check (bucket_type in ('browser', 'ip', 'account')), + bucket_hash text not null, + user_id uuid references auth.users(id) on delete cascade, + window_started_at timestamptz not null, + check_count integer not null default 0 check (check_count >= 0), + last_check_at timestamptz not null default now(), + unique (bucket_type, bucket_hash, window_started_at) +); + +create index if not exists looney_check_rate_limits_user_idx + on public.looney_check_rate_limits (user_id, window_started_at); + +create extension if not exists pg_cron with schema extensions; + +do $$ +begin + if not exists ( + select 1 from cron.job where jobname = 'looney-check-rate-limit-retention' + ) then + perform cron.schedule( + 'looney-check-rate-limit-retention', + '17 3 * * *', + $job$delete from public.looney_check_rate_limits where window_started_at < now() - interval '7 days'$job$ + ); + end if; +end; +$$; + +alter table public.looney_check_rate_limits enable row level security; + +revoke all on public.looney_check_rate_limits from anon, authenticated; + +create or replace function public.consume_looney_check_rate_limit( + p_buckets jsonb, + p_limit integer default 5, + p_consume boolean default true +) +returns table ( + allowed boolean, + retry_after_seconds integer, + browser_count integer, + ip_count integer, + account_count integer +) +language plpgsql +security definer +set search_path = public +as $$ +declare + bucket jsonb; + bucket_type_value text; + bucket_hash_value text; + current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc'; + bucket_count integer; + blocked boolean := false; + browser_total integer := 0; + ip_total integer := 0; + account_total integer := 0; +begin + if p_limit is null or p_limit < 1 then + raise exception 'Rate-limit must be positive'; + end if; + + if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then + raise exception 'At least one rate-limit bucket is required'; + end if; + + for bucket in + select value + from ( + select distinct on (value->>'type', value->>'hash') value + from jsonb_array_elements(p_buckets) + order by value->>'type', value->>'hash' + ) unique_buckets + loop + bucket_type_value := bucket->>'type'; + bucket_hash_value := bucket->>'hash'; + if bucket_type_value not in ('browser', 'ip', 'account') or bucket_hash_value is null or length(bucket_hash_value) < 16 then + raise exception 'Invalid rate-limit bucket'; + end if; + + insert into public.looney_check_rate_limits (bucket_type, bucket_hash, user_id, window_started_at) + values (bucket_type_value, bucket_hash_value, nullif(bucket->>'user_id', '')::uuid, current_window) + on conflict (bucket_type, bucket_hash, window_started_at) do nothing; + + select check_count into bucket_count + from public.looney_check_rate_limits + where bucket_type = bucket_type_value + and bucket_hash = bucket_hash_value + and window_started_at = current_window + for update; + + if bucket_type_value = 'browser' then browser_total := bucket_count; end if; + if bucket_type_value = 'ip' then ip_total := bucket_count; end if; + if bucket_type_value = 'account' then account_total := bucket_count; end if; + if bucket_count >= p_limit then blocked := true; end if; + end loop; + + if not blocked and p_consume then + update public.looney_check_rate_limits as limits + set check_count = limits.check_count + 1, last_check_at = now() + from ( + select distinct on (value->>'type', value->>'hash') value + from jsonb_array_elements(p_buckets) + order by value->>'type', value->>'hash' + ) unique_buckets + where limits.bucket_type = unique_buckets.value->>'type' + and limits.bucket_hash = unique_buckets.value->>'hash' + and limits.window_started_at = current_window; + browser_total := browser_total + case when exists ( + select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'browser' + ) then 1 else 0 end; + ip_total := ip_total + case when exists ( + select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'ip' + ) then 1 else 0 end; + account_total := account_total + case when exists ( + select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'account' + ) then 1 else 0 end; + end if; + + return query select not blocked, + greatest(0, extract(epoch from ((current_window + interval '1 day') - now()))::integer), + browser_total, ip_total, account_total; +end; +$$; + +revoke all on function public.consume_looney_check_rate_limit(jsonb, integer, boolean) from public, anon, authenticated; +grant execute on function public.consume_looney_check_rate_limit(jsonb, integer, boolean) to service_role; + +create or replace function public.release_looney_check_rate_limit(p_buckets jsonb) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + bucket jsonb; + current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc'; +begin + if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then + raise exception 'At least one rate-limit bucket is required'; + end if; + + for bucket in + select value + from ( + select distinct on (value->>'type', value->>'hash') value + from jsonb_array_elements(p_buckets) + order by value->>'type', value->>'hash' + ) unique_buckets + loop + update public.looney_check_rate_limits + set check_count = greatest(0, check_count - 1), last_check_at = now() + where bucket_type = bucket->>'type' + and bucket_hash = bucket->>'hash' + and window_started_at = current_window + and check_count > 0; + end loop; +end; +$$; + +revoke all on function public.release_looney_check_rate_limit(jsonb) from public, anon, authenticated; +grant execute on function public.release_looney_check_rate_limit(jsonb) to service_role; diff --git a/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql b/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql new file mode 100644 index 0000000..7d50af8 --- /dev/null +++ b/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql @@ -0,0 +1,34 @@ +create or replace function public.release_looney_check_rate_limit(p_buckets jsonb) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + bucket jsonb; + current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc'; +begin + if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then + raise exception 'At least one rate-limit bucket is required'; + end if; + + for bucket in + select value + from ( + select distinct on (value->>'type', value->>'hash') value + from jsonb_array_elements(p_buckets) + order by value->>'type', value->>'hash' + ) unique_buckets + loop + update public.looney_check_rate_limits + set check_count = greatest(0, check_count - 1), last_check_at = now() + where bucket_type = bucket->>'type' + and bucket_hash = bucket->>'hash' + and window_started_at = current_window + and check_count > 0; + end loop; +end; +$$; + +revoke all on function public.release_looney_check_rate_limit(jsonb) from public, anon, authenticated; +grant execute on function public.release_looney_check_rate_limit(jsonb) to service_role; diff --git a/vite.config.ts b/vite.config.ts index 8babdc8..b14b4ed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,10 @@ -import { defineConfig, loadEnv } from "vite"; +import { defineConfig } from "vite"; import sitemap from 'vite-plugin-sitemap'; import react from "@vitejs/plugin-react-swc"; import path from "path"; // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - +export default defineConfig(() => { return { server: { host: "::", @@ -65,8 +63,5 @@ export default defineConfig(({ mode }) => { }, }, }, - define: { - 'process.env': env - } }; });