diff --git a/package-lock.json b/package-lock.json index b29c0388..debcc72a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.10.4", + "version": "2.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.10.4", + "version": "2.10.5", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", @@ -5768,7 +5768,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5782,7 +5781,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5796,7 +5794,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5810,7 +5807,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5824,7 +5820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5838,7 +5833,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5852,7 +5846,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5866,7 +5859,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5880,7 +5872,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5894,7 +5885,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5908,7 +5898,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5922,7 +5911,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5936,7 +5924,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5950,7 +5937,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5964,7 +5950,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5978,7 +5963,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5992,7 +5976,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6006,7 +5989,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6020,7 +6002,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6034,7 +6015,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6048,7 +6028,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6062,7 +6041,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6076,7 +6054,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6090,7 +6067,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6104,7 +6080,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/public/cartridge.png b/public/cartridge.png new file mode 100644 index 00000000..8b640428 Binary files /dev/null and b/public/cartridge.png differ diff --git a/src/components/FileMetadataContent.tsx b/src/components/FileMetadataContent.tsx index 1f1344be..5d146a72 100644 --- a/src/components/FileMetadataContent.tsx +++ b/src/components/FileMetadataContent.tsx @@ -11,6 +11,7 @@ import { getDisplayName } from '@/lib/getDisplayName'; import { genUserName } from '@/lib/genUserName'; import { getAvatarShape } from '@/lib/avatarShape'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; /** Extract the first value of a tag by name. */ function getTag(tags: string[][], name: string): string | undefined { @@ -25,10 +26,18 @@ function formatBytes(bytes: number): string { } /** YouTube-style description card rendered below media. */ -function DescriptionCard({ text }: { text: string }) { +function DescriptionCard({ title, text }: { title?: string; text?: string }) { + if (!title && !text) return null; return (
-

{text}

+ {title && ( +

{title}

+ )} + {text && ( +

+ {text} +

+ )}
); } @@ -101,10 +110,10 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps - {description && } + ); } diff --git a/src/components/WebxdcEmbed.tsx b/src/components/WebxdcEmbed.tsx index 6f3d0f79..e6916aff 100644 --- a/src/components/WebxdcEmbed.tsx +++ b/src/components/WebxdcEmbed.tsx @@ -1,11 +1,13 @@ import { useState, useRef, useCallback, useEffect, forwardRef } from 'react'; import { createPortal } from 'react-dom'; -import { Blocks, Play, X, Gamepad2 } from 'lucide-react'; +import { Blocks, X, Gamepad2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Webxdc, type WebxdcHandle } from '@/components/Webxdc'; import { GameControls } from '@/components/GameControls'; import { useCenterColumn } from '@/contexts/LayoutContext'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCardTilt } from '@/hooks/useCardTilt'; +import { useDominantColor } from '@/hooks/useDominantColor'; import { useWebxdc } from '@/hooks/useWebxdc'; import { deriveIframeSubdomain } from '@/lib/iframeSubdomain'; import { cn } from '@/lib/utils'; @@ -19,6 +21,12 @@ export interface WebxdcEmbedProps { name?: string; /** App icon URL. */ icon?: string; + /** + * If true, renders a description-style card below the cartridge with the + * app name. Defaults to true. Set to false when a parent component is + * rendering its own card (e.g. `FileMetadataContent`). + */ + showNameCard?: boolean; className?: string; } @@ -51,7 +59,7 @@ function useElementRect(el: HTMLElement | null): Rect | null { * then opens a fullscreen panel (covering the center column on desktop, the * full screen on mobile) when the user clicks Play — matching the nsite UX. */ -export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedProps) { +export function WebxdcEmbed({ url, uuid, name, icon, showNameCard = true, className }: WebxdcEmbedProps) { const [launched, setLaunched] = useState(false); const [showGamepad, setShowGamepad] = useState(false); const webxdcHandleRef = useRef(null); @@ -77,36 +85,22 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro }, []); if (!launched) { + const appName = name ?? 'Webxdc App'; return (
e.stopPropagation()} > -
- {icon ? ( - {name - ) : ( -
- -
- )} -

{name ?? 'Webxdc App'}

- -
+ setLaunched(true)} + /> + {showNameCard && ( +
+

{appName}

+
+ )}
); } @@ -190,6 +184,112 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro ); } +/** + * Interactive cartridge button with a 3D mouse-tilt effect. The tilt hook + * needs to own the pointer events on a `
`, so the button sits inside + * the tilted wrapper rather than the other way around. + */ +function WebxdcCartridgeButton({ + appName, + icon, + onLaunch, +}: { + appName: string; + icon?: string; + onLaunch: () => void; +}) { + // Matches the subtle feel used by encrypted letters (18° max, 1.03x scale). + const tilt = useCardTilt(18, 1.03, 800); + const dominant = useDominantColor(icon); + + // Boost saturation + keep mid-lightness so the tint reads vividly when + // blended with the cartridge's mid-gray shading via `mix-blend-mode: color`. + const tintColor = dominant + ? `hsl(${dominant.h.toFixed(1)}, ${Math.min(100, Math.max(70, dominant.s * 100 + 20)).toFixed(1)}%, 50%)` + : null; + + return ( +
+ +
+ ); +} + /** * Inner component that renders the actual webxdc iframe. * Separated so the useWebxdc hook only runs when the app is launched. diff --git a/src/hooks/useDominantColor.ts b/src/hooks/useDominantColor.ts new file mode 100644 index 00000000..588e6095 --- /dev/null +++ b/src/hooks/useDominantColor.ts @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; + +/** RGB triple returned by the hook, or null if extraction failed. */ +export interface DominantColor { + r: number; + g: number; + b: number; + /** HSL representation for easy tinting. */ + h: number; + s: number; + l: number; +} + +/** In-memory cache so repeat renders of the same icon don't re-sample. */ +const cache = new Map(); + +/** Downscaled canvas edge for sampling — keeps cost tiny. */ +const SAMPLE_SIZE = 32; + +/** Pixels with alpha below this are skipped. */ +const ALPHA_THRESHOLD = 128; + +/** Pixels with very low saturation (near gray/white/black) are skipped. */ +const MIN_SATURATION = 0.15; + +/** Pixels this close to pure white or black are skipped. */ +const L_EDGE = 0.08; + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + let h = 0; + let s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; + case gn: h = (bn - rn) / d + 2; break; + case bn: h = (rn - gn) / d + 4; break; + } + h /= 6; + } + return { h: h * 360, s, l }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + const hn = h / 360; + if (s === 0) { + const v = Math.round(l * 255); + return { r: v, g: v, b: v }; + } + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return { + r: Math.round(hue2rgb(p, q, hn + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, hn) * 255), + b: Math.round(hue2rgb(p, q, hn - 1 / 3) * 255), + }; +} + +/** + * Extract a dominant hue from an image by sampling pixels in a downscaled + * canvas and averaging the chromatic ones (skipping transparent, near-gray, + * near-white, and near-black pixels). + * + * Returns `null` if the image can't be loaded (CORS failure) or has no + * discernible dominant color (e.g. pure-grayscale icon). + */ +export function useDominantColor(url: string | undefined): DominantColor | null { + const [color, setColor] = useState(() => (url && cache.has(url) ? cache.get(url)! : null)); + + useEffect(() => { + if (!url) { setColor(null); return; } + if (cache.has(url)) { setColor(cache.get(url)!); return; } + + let cancelled = false; + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + if (cancelled) return; + try { + const canvas = document.createElement('canvas'); + canvas.width = SAMPLE_SIZE; + canvas.height = SAMPLE_SIZE; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { cache.set(url, null); setColor(null); return; } + + ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + const { data } = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + + // Hue histogram (18 buckets × 20°), weighted by saturation × chroma-ish. + const BUCKETS = 18; + const weights = new Float32Array(BUCKETS); + const sSum = new Float32Array(BUCKETS); + const lSum = new Float32Array(BUCKETS); + const counts = new Uint32Array(BUCKETS); + + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3]; + if (a < ALPHA_THRESHOLD) continue; + const r = data[i], g = data[i + 1], b = data[i + 2]; + const { h, s, l } = rgbToHsl(r, g, b); + if (s < MIN_SATURATION) continue; + if (l < L_EDGE || l > 1 - L_EDGE) continue; + const bucket = Math.min(BUCKETS - 1, Math.floor((h / 360) * BUCKETS)); + // Weight by saturation so vivid pixels dominate over muted ones. + const w = s; + weights[bucket] += w; + sSum[bucket] += s * w; + lSum[bucket] += l * w; + counts[bucket] += 1; + } + + // Find the bucket with the highest total weight. + let best = -1, bestWeight = 0; + for (let i = 0; i < BUCKETS; i++) { + if (weights[i] > bestWeight) { bestWeight = weights[i]; best = i; } + } + + if (best < 0 || counts[best] < 4) { + cache.set(url, null); + setColor(null); + return; + } + + // Bucket midpoint hue, saturation/lightness averaged within the bucket. + const h = (best + 0.5) * (360 / BUCKETS); + const s = sSum[best] / weights[best]; + const l = lSum[best] / weights[best]; + const rgb = hslToRgb(h, s, l); + const result: DominantColor = { ...rgb, h, s, l }; + cache.set(url, result); + setColor(result); + } catch { + // Canvas taint (CORS) or other failure — fall back to null. + cache.set(url, null); + setColor(null); + } + }; + + img.onerror = () => { + if (cancelled) return; + cache.set(url, null); + setColor(null); + }; + + img.src = url; + + return () => { cancelled = true; }; + }, [url]); + + return color; +}