From 0458c9ae2cf6fc076fe4cf6affc9745624186c4a Mon Sep 17 00:00:00 2001
From: Remco Stoeten
Date: Sat, 1 Aug 2026 22:54:04 +0200
Subject: [PATCH 1/4] feat(ytmusic): native TS parser with cache, drop python
bridge, add /ytmusic page
---
.env.example | 5 +-
src/app/(marketing)/ytmusic/page.tsx | 41 ++
src/app/api/activity/combined/combine.ts | 16 +-
src/app/api/ytmusic/recent/route.ts | 45 +-
.../landing/activity/contribution-graph.tsx | 70 +++-
src/components/landing/activity/section.tsx | 11 +-
.../components/ytmusic-diagnostics.tsx | 391 ++++++++++++++++++
src/features/ytmusic/types.ts | 23 ++
src/server/env.ts | 4 +
src/server/ytmusic/auth.ts | 200 ++++-----
src/server/ytmusic/cache.ts | 50 +++
src/server/ytmusic/index.ts | 7 +-
src/server/ytmusic/parser.ts | 170 ++++++++
src/server/ytmusic/python-bridge.ts | 91 ----
src/server/ytmusic/tracks.ts | 383 ++++++-----------
15 files changed, 1036 insertions(+), 471 deletions(-)
create mode 100644 src/app/(marketing)/ytmusic/page.tsx
create mode 100644 src/features/ytmusic/components/ytmusic-diagnostics.tsx
create mode 100644 src/server/ytmusic/cache.ts
create mode 100644 src/server/ytmusic/parser.ts
delete mode 100644 src/server/ytmusic/python-bridge.ts
diff --git a/.env.example b/.env.example
index c216b27c..93746e3d 100644
--- a/.env.example
+++ b/.env.example
@@ -47,7 +47,10 @@ SPOTIFY_REFRESH_TOKEN=""
# ============================================================
# YouTube Music
# ============================================================
-YTM_AUTH_USER=""
+# Browser profile index used by YouTube Music (usually 0).
+YTM_AUTH_USER="0"
+# Full Cookie request header copied from an authenticated music.youtube.com request.
+# Keep this server-side and replace it when the diagnostic page reports unauthorized.
YTM_COOKIE=""
# ============================================================
diff --git a/src/app/(marketing)/ytmusic/page.tsx b/src/app/(marketing)/ytmusic/page.tsx
new file mode 100644
index 00000000..db9618fc
--- /dev/null
+++ b/src/app/(marketing)/ytmusic/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from 'next'
+import { Suspense } from 'react'
+import { connection } from 'next/server'
+import { createPageMetadata } from '@/core/metadata/base'
+import { YTMusicDiagnostics } from '@/features/ytmusic/components/ytmusic-diagnostics'
+import { getYTMusicResult } from '@/server/ytmusic'
+
+export const metadata: Metadata = createPageMetadata({
+ title: 'YouTube Music signal check',
+ description:
+ 'Live diagnostics for the YouTube Music listening-history integration.',
+ canonical: '/ytmusic',
+ noIndex: true
+})
+
+export default function YTMusicPage() {
+ return (
+ }>
+
+
+ )
+}
+
+async function LiveDiagnostics() {
+ await connection()
+ const initialResult = await getYTMusicResult(20)
+ return
+}
+
+function DiagnosticsSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/app/api/activity/combined/combine.ts b/src/app/api/activity/combined/combine.ts
index f29a01f0..90915c9c 100644
--- a/src/app/api/activity/combined/combine.ts
+++ b/src/app/api/activity/combined/combine.ts
@@ -3,7 +3,7 @@ import {
getCachedGitHubContributions
} from '@/server/github'
import { getSpotifyTracks } from '@/server/spotify'
-import { getYTMusicTracks, hasYTMusicCredentials } from '@/server/ytmusic'
+import { getYTMusicTracks } from '@/server/ytmusic'
import type { CombinedActivityResponse } from './types'
export async function getCombinedActivity(
@@ -17,17 +17,17 @@ export async function getCombinedActivity(
currentYearContributions,
previousYearContributions,
recentActivity,
- spotifyTracks,
- ytmTracks
+ spotifyTracks
] = await Promise.all([
getCachedGitHubContributions(currentYear),
getCachedGitHubContributions(previousYear),
getCachedGitHubActivity(activityLimit),
- getSpotifyTracks(tracksLimit),
- hasYTMusicCredentials()
- ? getYTMusicTracks(tracksLimit)
- : Promise.resolve([] as any[])
+ getSpotifyTracks(tracksLimit)
])
+ const tracks =
+ spotifyTracks.length > 0
+ ? spotifyTracks
+ : await getYTMusicTracks(tracksLimit)
const contributionsMap: Record<
string,
@@ -47,8 +47,6 @@ export async function getCombinedActivity(
}
}
- const tracks = spotifyTracks.length > 0 ? spotifyTracks : ytmTracks
-
return {
contributions: Object.values(contributionsMap),
totalContributions:
diff --git a/src/app/api/ytmusic/recent/route.ts b/src/app/api/ytmusic/recent/route.ts
index 69866847..da6652dd 100644
--- a/src/app/api/ytmusic/recent/route.ts
+++ b/src/app/api/ytmusic/recent/route.ts
@@ -1,9 +1,8 @@
import { NextResponse } from 'next/server'
-import { getYTMusicTracks } from '@/server/ytmusic/tracks'
-import { hasYTMusicCredentials } from '@/server/ytmusic/auth'
+import { unstable_rethrow } from 'next/navigation'
+import { getYTMusicResult } from '@/server/ytmusic/tracks'
import { parseBoundedIntParam } from '@/shared/lib/request-params'
-
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
@@ -12,21 +11,39 @@ export async function GET(request: Request) {
min: 1,
max: 50
})
+ const forceRefresh = searchParams.get('refresh') === '1'
+ const result = await getYTMusicResult(limit, { forceRefresh })
+ const status = getHttpStatus(result.status)
- if (!hasYTMusicCredentials()) {
- return NextResponse.json(
- { error: 'No YouTube Music cookie configured', tracks: [] },
- { status: 200 }
- )
- }
-
- const tracks = await getYTMusicTracks(limit)
- return NextResponse.json({ tracks })
+ return NextResponse.json(result, {
+ status,
+ headers: { 'Cache-Control': 'private, no-store' }
+ })
} catch (error) {
+ unstable_rethrow(error)
console.error('[YTM API] Error:', error)
return NextResponse.json(
- { error: 'Failed to fetch tracks', tracks: [] },
- { status: 500 }
+ {
+ status: 'error',
+ source: 'none',
+ tracks: [],
+ message: 'The YouTube Music endpoint failed unexpectedly.',
+ credentialsConfigured: false,
+ isStale: false,
+ fetchedAt: new Date().toISOString(),
+ cacheUpdatedAt: null
+ },
+ {
+ status: 500,
+ headers: { 'Cache-Control': 'private, no-store' }
+ }
)
}
}
+
+function getHttpStatus(status: string): number {
+ if (status === 'unauthorized') return 401
+ if (status === 'unconfigured') return 503
+ if (status === 'error') return 502
+ return 200
+}
diff --git a/src/components/landing/activity/contribution-graph.tsx b/src/components/landing/activity/contribution-graph.tsx
index 1bac6818..a0c35a0f 100644
--- a/src/components/landing/activity/contribution-graph.tsx
+++ b/src/components/landing/activity/contribution-graph.tsx
@@ -415,6 +415,50 @@ export function ActivityContributionGraph({
return activityData.reduce((sum, day) => sum + day.githubCount, 0)
}, [activityData])
+ const cellRefs = useRef
diff --git a/src/features/miscellaneous/components/tool-quick-nav.tsx b/src/features/miscellaneous/components/tool-quick-nav.tsx
index ffa15c6d..b6c947d6 100644
--- a/src/features/miscellaneous/components/tool-quick-nav.tsx
+++ b/src/features/miscellaneous/components/tool-quick-nav.tsx
@@ -35,7 +35,7 @@ export function ToolQuickNav({ currentSlug }: Props) {
href={`/tools/${tool.slug}`}
aria-current={isCurrent ? 'page' : undefined}
className={cn(
- 'inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ 'ai-trigger inline-flex items-center gap-1.5 overflow-visible rounded-sm border px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isCurrent
? 'border-foreground/50 bg-foreground/10 text-foreground'
: 'border-border/50 text-muted-foreground hover:border-border hover:text-foreground'
diff --git a/src/features/miscellaneous/components/tool-renderer.tsx b/src/features/miscellaneous/components/tool-renderer.tsx
index bd7848d4..dce6a0e3 100644
--- a/src/features/miscellaneous/components/tool-renderer.tsx
+++ b/src/features/miscellaneous/components/tool-renderer.tsx
@@ -1,10 +1,8 @@
'use client'
-import { useEffect } from 'react'
import nextDynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import type { TToolSlug } from '../constants/tools'
-import { useRecentTools } from '../hooks/use-tool-usage'
function ToolSkeleton() {
return (
@@ -45,12 +43,6 @@ type Props = {
}
export function ToolRenderer({ slug }: Props) {
- const { markUsed } = useRecentTools()
-
- useEffect(() => {
- markUsed(slug)
- }, [slug, markUsed])
-
const Tool = TOOL_COMPONENTS[slug]
if (!Tool) return null
diff --git a/src/features/miscellaneous/components/tool-seo-content.tsx b/src/features/miscellaneous/components/tool-seo-content.tsx
index 905be45d..f9ec2604 100644
--- a/src/features/miscellaneous/components/tool-seo-content.tsx
+++ b/src/features/miscellaneous/components/tool-seo-content.tsx
@@ -71,24 +71,6 @@ export function ToolSeoContent({ name, content }: Props) {
{content.formats}
-
-
-
- Frequently asked questions
-
-
- {content.faqs.map(faq => (
-
-
- {faq.question}
-
-
- {faq.answer}
-
-
- ))}
-
-
)
}
diff --git a/src/features/miscellaneous/components/tools-hub.tsx b/src/features/miscellaneous/components/tools-hub.tsx
index 47e725ed..bce3247f 100644
--- a/src/features/miscellaneous/components/tools-hub.tsx
+++ b/src/features/miscellaneous/components/tools-hub.tsx
@@ -1,21 +1,19 @@
'use client'
-import { useDeferredValue, useMemo, useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
-import { Search, Star, Clock } from 'lucide-react'
+import { Search, Star } from 'lucide-react'
import { useShortcutMap } from '@remcostoeten/use-shortcut/react'
import { Input } from '@/components/ui/input'
import { Section } from '@/components/ui/section'
import { cn } from '@/shared/lib/cn'
import {
- getToolBySlug,
getToolCountsByCategory,
searchTools,
TOOL_CATEGORIES,
TOOL_CATEGORY_LABELS,
TOOLS
} from '../constants/tools'
-import { useRecentTools } from '../hooks/use-tool-usage'
import type { TToolCategory, TToolDefinition } from '../types'
import { ToolCard } from './tool-card'
@@ -45,17 +43,22 @@ function CategoryFilters({
onChange: (category: TCategoryFilter) => void
}) {
const counts = getToolCountsByCategory()
- const options: { value: TCategoryFilter; label: string; count: number }[] = [
- { value: 'all', label: 'All', count: TOOLS.length },
- ...TOOL_CATEGORIES.map(category => ({
- value: category,
- label: TOOL_CATEGORY_LABELS[category],
- count: counts[category]
- }))
- ]
+ const options: { value: TCategoryFilter; label: string; count: number }[] =
+ [
+ { value: 'all', label: 'All', count: TOOLS.length },
+ ...TOOL_CATEGORIES.map(category => ({
+ value: category,
+ label: TOOL_CATEGORY_LABELS[category],
+ count: counts[category]
+ }))
+ ]
return (
-
+
{options.map(option => (
))}
)
}
-function RecentTools() {
- const { recent, hydrated } = useRecentTools()
-
- if (!hydrated || recent.length === 0) return null
-
- const tools = recent
- .map(slug => getToolBySlug(slug))
- .filter((tool): tool is TToolDefinition => Boolean(tool))
-
- if (tools.length === 0) return null
-
- return (
-
-
-
-
- Continue where you left off
-
-
-
-
- )
-}
-
export function ToolsHub({ intro }: Props) {
const [query, setQuery] = useState('')
const [category, setCategory] = useState
('all')
- const deferredQuery = useDeferredValue(query)
const searchRef = useRef(null)
useShortcutMap({
@@ -116,11 +96,11 @@ export function ToolsHub({ intro }: Props) {
})
const tools = useMemo(() => {
- const matches = searchTools(deferredQuery)
+ const matches = searchTools(query)
return category === 'all'
? matches
: matches.filter(tool => tool.category === category)
- }, [deferredQuery, category])
+ }, [query, category])
return (
@@ -148,7 +128,10 @@ export function ToolsHub({ intro }: Props) {
/>
-
+
-
-
{tools.length > 0 ? (
diff --git a/src/features/miscellaneous/constants/tool-seo.ts b/src/features/miscellaneous/constants/tool-seo.ts
index 7ee85bf6..21c3e882 100644
--- a/src/features/miscellaneous/constants/tool-seo.ts
+++ b/src/features/miscellaneous/constants/tool-seo.ts
@@ -1,10 +1,5 @@
import type { TToolSlug } from './tools'
-export type TToolFaq = {
- question: string
- answer: string
-}
-
export type TToolSeoContent = {
metaTitle: string
metaDescription: string
@@ -12,7 +7,6 @@ export type TToolSeoContent = {
highlights: readonly string[]
steps: readonly string[]
formats: string
- faqs: readonly TToolFaq[]
}
const TOOL_SEO_CONTENT = {
@@ -32,21 +26,7 @@ const TOOL_SEO_CONTENT = {
'Copy the generated TSX or export the complete collection as files or a ZIP.'
],
formats:
- 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.',
- faqs: [
- {
- question: 'Are my SVG files uploaded?',
- answer: 'No. Parsing, sanitizing and component generation happen locally in your browser.'
- },
- {
- question: 'Can I convert multiple SVG icons at once?',
- answer: 'Yes. You can process a collection, rename components and export individual files or one combined package.'
- },
- {
- question: 'Does it prevent duplicate SVG IDs?',
- answer: 'Yes. Internal IDs and their references are rewritten so multiple generated icons can render safely on the same page.'
- }
- ]
+ 'Input: SVG markup and .svg files. Output: React TSX components, combined registries and downloadable ZIP packages.'
},
'sendable-video': {
metaTitle: 'WhatsApp Video Converter — MOV/MKV to MP4',
@@ -64,22 +44,7 @@ const TOOL_SEO_CONTENT = {
'Export a compatible MP4 or an optimized GIF and download it locally.'
],
formats:
- 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.',
- faqs: [
- {
- question:
- 'Why does a video work locally but not in WhatsApp Web?',
- answer: 'The container may be supported while its video or audio codec is not. Re-encoding to a conventional H.264 MP4 resolves many compatibility problems.'
- },
- {
- question: 'Is my video uploaded anywhere?',
- answer: 'No. The file is decoded and encoded locally in your browser and remains on your device.'
- },
- {
- question: 'Can I trim the video before converting it?',
- answer: 'Yes. Set the clip range in the preview before exporting to avoid encoding footage you do not need.'
- }
- ]
+ 'Input: MP4, MOV, MKV, WebM and AVI. Output: chat-friendly H.264 MP4 or an optimized animated GIF.'
},
'gif-to-video': {
metaTitle: 'GIF to MP4 & WebM Converter',
@@ -97,21 +62,7 @@ const TOOL_SEO_CONTENT = {
'Convert, inspect the preview and download the finished video.'
],
formats:
- 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.',
- faqs: [
- {
- question: 'Why convert a GIF to MP4 or WebM?',
- answer: 'Video compression is substantially more efficient than animated GIF compression, so the result is often smaller at similar visual quality.'
- },
- {
- question: 'Will the animation still loop?',
- answer: 'The complete animation is preserved in the exported video. Whether it loops during playback depends on the website or video player.'
- },
- {
- question: 'Does the GIF get uploaded?',
- answer: 'No. Conversion runs locally in the browser, and the input and output stay on your device.'
- }
- ]
+ 'Input: animated GIF files. Output: H.264 MP4 for compatibility or WebM for efficient web delivery.'
},
'video-to-gif': {
metaTitle: 'Video to GIF Converter — MP4, MOV & WebM',
@@ -129,22 +80,7 @@ const TOOL_SEO_CONTENT = {
'Render the looping GIF, review it and download the result.'
],
formats:
- 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.',
- faqs: [
- {
- question: 'How can I make the GIF file smaller?',
- answer: 'Trim the clip, reduce its width or lower the frame rate. Shorter dimensions and fewer frames usually have the largest effect.'
- },
- {
- question:
- 'Can I preview the result before the full conversion?',
- answer: 'Yes. The preview renders a short sample and estimates the complete file size using the selected settings.'
- },
- {
- question: 'Is the source video uploaded?',
- answer: 'No. FFmpeg runs inside your browser, so the source video and generated GIF remain local.'
- }
- ]
+ 'Input: MP4, MOV, MKV, WebM and AVI video. Output: optimized animated GIF with a generated color palette.'
}
} as const satisfies Partial
>
diff --git a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
index a4b6d1d4..9781e170 100644
--- a/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
+++ b/src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import type { TMediaOutput, TMediaStatus } from '../../types/media'
import {
deleteQuiet,
@@ -58,6 +59,29 @@ export function useGifToVideoStore() {
const [output, setOutput] = useState(null)
const urlsRef = useRef([])
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ mime: session.output.mime
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current = [fileUrl, output?.url].filter(
@@ -73,16 +97,18 @@ export function useGifToVideoStore() {
)
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
clearOutput()
setFileUrl(previous => {
if (previous) URL.revokeObjectURL(previous)
@@ -91,6 +117,7 @@ export function useGifToVideoStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -98,6 +125,7 @@ export function useGifToVideoStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -106,6 +134,7 @@ export function useGifToVideoStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -113,7 +142,7 @@ export function useGifToVideoStore() {
mode: 'idle'
})
},
- [busy, clearOutput]
+ [busy, clearOutput, persistFile]
)
const setFormat = useCallback(
@@ -167,9 +196,15 @@ export function useGifToVideoStore() {
outputName,
OUTPUT_MIMES[options.format]
)
+ const outputFileName = `${stem(file.name)}.${options.format}`
+ persistOutput({
+ blob,
+ name: outputFileName,
+ mime: OUTPUT_MIMES[options.format]
+ })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}.${options.format}`,
+ name: outputFileName,
size: blob.size,
mime: OUTPUT_MIMES[options.format]
})
@@ -187,7 +222,7 @@ export function useGifToVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, options])
+ }, [busy, clearOutput, file, options, persistOutput])
return {
file,
diff --git a/src/features/miscellaneous/hooks/use-media-session.ts b/src/features/miscellaneous/hooks/use-media-session.ts
new file mode 100644
index 00000000..9b19e03f
--- /dev/null
+++ b/src/features/miscellaneous/hooks/use-media-session.ts
@@ -0,0 +1,69 @@
+'use client'
+
+import { useCallback, useEffect, useRef } from 'react'
+import type { TTrimRange } from '../types/media'
+import {
+ loadMediaSession,
+ saveMediaFile,
+ saveMediaOutput,
+ saveMediaTrim,
+ type TPersistedOutput,
+ type TPersistedSession
+} from '../utils/media-persistence'
+
+export type TRestoredMediaSession = TPersistedSession & { file: File }
+
+function warn(error: unknown) {
+ console.warn('Media session persistence failed', error)
+}
+
+/**
+ * Restores a tool's last media session from IndexedDB on mount and exposes
+ * fire-and-forget persistence for the input file, trim range, and output.
+ * onRestore only fires when a file was previously saved.
+ */
+export function useMediaSession(
+ toolKey: string,
+ onRestore: (session: TRestoredMediaSession) => void
+) {
+ const onRestoreRef = useRef(onRestore)
+ const startedRef = useRef(false)
+
+ useEffect(() => {
+ onRestoreRef.current = onRestore
+ })
+
+ useEffect(() => {
+ if (startedRef.current) return
+ startedRef.current = true
+ loadMediaSession(toolKey)
+ .then(session => {
+ if (!session.file) return
+ onRestoreRef.current({ ...session, file: session.file })
+ })
+ .catch(warn)
+ }, [toolKey])
+
+ const persistFile = useCallback(
+ (file: File | null) => {
+ saveMediaFile(toolKey, file).catch(warn)
+ },
+ [toolKey]
+ )
+
+ const persistTrim = useCallback(
+ (trim: TTrimRange | null) => {
+ saveMediaTrim(toolKey, trim).catch(warn)
+ },
+ [toolKey]
+ )
+
+ const persistOutput = useCallback(
+ (output: TPersistedOutput | null) => {
+ saveMediaOutput(toolKey, output).catch(warn)
+ },
+ [toolKey]
+ )
+
+ return { persistFile, persistTrim, persistOutput }
+}
diff --git a/src/features/miscellaneous/hooks/use-tool-usage.ts b/src/features/miscellaneous/hooks/use-tool-usage.ts
deleted file mode 100644
index 29cd3541..00000000
--- a/src/features/miscellaneous/hooks/use-tool-usage.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client'
-
-import { useCallback } from 'react'
-import { useLocalStorage } from './use-local-storage'
-
-const RECENT_KEY = 'misc-tools:recent'
-const MAX_RECENT = 6
-
-export function useRecentTools() {
- const [recent, setRecent, hydrated] = useLocalStorage(
- RECENT_KEY,
- []
- )
-
- const markUsed = useCallback(
- (slug: string) => {
- setRecent(previous =>
- [slug, ...previous.filter(item => item !== slug)].slice(
- 0,
- MAX_RECENT
- )
- )
- },
- [setRecent]
- )
-
- return { recent, markUsed, hydrated }
-}
diff --git a/src/features/miscellaneous/hooks/use-trim-state.ts b/src/features/miscellaneous/hooks/use-trim-state.ts
index 22842fbe..52a62f4b 100644
--- a/src/features/miscellaneous/hooks/use-trim-state.ts
+++ b/src/features/miscellaneous/hooks/use-trim-state.ts
@@ -40,13 +40,17 @@ export function useTrimState() {
setHistory([])
}, [])
- const handleMetadata = useCallback((nextDuration: number) => {
- if (!Number.isFinite(nextDuration) || nextDuration <= 0) return
- const full = { start: 0, end: nextDuration }
- setDuration(nextDuration)
- setTrim(full)
- setHistory([full])
- }, [])
+ const handleMetadata = useCallback(
+ (nextDuration: number, initial?: TTrimRange) => {
+ if (!Number.isFinite(nextDuration) || nextDuration <= 0) return
+ const full = { start: 0, end: nextDuration }
+ const start = initial ? clampTrim(initial, nextDuration) : full
+ setDuration(nextDuration)
+ setTrim(start)
+ setHistory(rangesEqual(start, full) ? [full] : [full, start])
+ },
+ []
+ )
const updateTrim = useCallback(
(next: TTrimRange) => {
diff --git a/src/features/miscellaneous/sendable-video/components/export-panel.tsx b/src/features/miscellaneous/sendable-video/components/export-panel.tsx
index 268fb65a..cb47ec95 100644
--- a/src/features/miscellaneous/sendable-video/components/export-panel.tsx
+++ b/src/features/miscellaneous/sendable-video/components/export-panel.tsx
@@ -3,7 +3,10 @@
import { Download, Film, Image as ImageIcon, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/shared/lib/cn'
-import { Segmented, ToggleChip } from '../../link-extractor/components/segmented'
+import {
+ Segmented,
+ ToggleChip
+} from '../../link-extractor/components/segmented'
import { GIF_FPS_OPTIONS } from '../constants'
import type { TSendableVideoStore } from '../hooks/use-sendable-video-store'
import { bytesToHuman } from '../../utils/format'
@@ -64,18 +67,43 @@ export function ExportPanel({ store }: Props) {
Export GIF
{output ? (
-
+ <>
+
+
+ {output.kind === 'gif' ? (
+
+
+
+ Rendered GIF: {output.name} (
+ {bytesToHuman(output.size)})
+
+
+ ) : (
+
+ )}
+ >
) : null}
diff --git a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
index 503f3c7b..c52b4735 100644
--- a/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
+++ b/src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
@@ -3,7 +3,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import { useTrimState } from '../../hooks/use-trim-state'
+import type { TTrimRange } from '../../types/media'
import {
DEFAULT_OPTIONS,
GIF_PRESETS,
@@ -18,12 +20,18 @@ import {
deleteQuiet,
execWithLogs,
loadFFmpeg,
+ probeLogs,
readOutputBlob,
setFFmpegProgressHandler,
writeInputFile
} from '../../utils/ffmpeg'
import { stem } from '../../utils/format'
-import { encodeArgs, gifArgs, remuxArgs } from '../utils/ffmpeg'
+import {
+ encodeArgs,
+ gifArgs,
+ isRemuxCompatible,
+ remuxArgs
+} from '../utils/ffmpeg'
const IDLE_STATUS: TStatus = {
message: 'Select a video to get started.',
@@ -60,6 +68,31 @@ export function useSendableVideoStore() {
file: null,
output: null
})
+ const pendingTrimRef = useRef(null)
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistTrim, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ pendingTrimRef.current = session.trim
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ kind: session.output.mime === 'image/gif' ? 'gif' : 'mp4'
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current.file = fileUrl
@@ -79,16 +112,19 @@ export function useSendableVideoStore() {
)
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
+ pendingTrimRef.current = null
clearOutput()
setFileUrl(previous => {
if (previous) URL.revokeObjectURL(previous)
@@ -98,6 +134,7 @@ export function useSendableVideoStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -105,6 +142,7 @@ export function useSendableVideoStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -113,6 +151,7 @@ export function useSendableVideoStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -120,9 +159,28 @@ export function useSendableVideoStore() {
mode: 'idle'
})
},
- [busy, clearOutput, clearTrim]
+ [busy, clearOutput, clearTrim, persistFile]
)
+ const handleMetadata = useCallback(
+ (nextDuration: number) => {
+ trimState.handleMetadata(
+ nextDuration,
+ pendingTrimRef.current ?? undefined
+ )
+ pendingTrimRef.current = null
+ },
+ [trimState.handleMetadata]
+ )
+
+ useEffect(() => {
+ if (!file || trimState.duration <= 0) return
+ const timeout = setTimeout(() => {
+ persistTrim(hasTrim ? trim : null)
+ }, 300)
+ return () => clearTimeout(timeout)
+ }, [file, trimState.duration, hasTrim, trim, persistTrim])
+
const setMuteAudio = useCallback(
(muteAudio: boolean) => {
setOptions(previous => ({ ...previous, muteAudio }))
@@ -168,7 +226,10 @@ export function useSendableVideoStore() {
})
let blob: Blob | null = null
- if (!options.muteAudio) {
+ if (
+ !options.muteAudio &&
+ isRemuxCompatible(await probeLogs(ffmpeg, INPUT_NAME))
+ ) {
try {
await execWithLogs(ffmpeg, remuxArgs(range))
blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4')
@@ -183,22 +244,21 @@ export function useSendableVideoStore() {
message: 'Re-encoding for compatibility…',
mode: 'processing'
})
- await execWithLogs(
- ffmpeg,
- encodeArgs(range, options.muteAudio)
- )
+ await execWithLogs(ffmpeg, encodeArgs(range, options.muteAudio))
blob = await readOutputBlob(ffmpeg, OUTPUT_MP4, 'video/mp4')
}
+ const outputName = `${stem(file.name)}_sendable.mp4`
+ persistOutput({ blob, name: outputName, mime: 'video/mp4' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_sendable.mp4`,
+ name: outputName,
size: blob.size,
kind: 'mp4'
})
setProgress(100)
setStatus({
- message: 'Done — your MP4 is ready to download.',
+ message: 'Done - your MP4 is ready to download.',
mode: 'success'
})
@@ -210,7 +270,7 @@ export function useSendableVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, hasTrim, options.muteAudio, trim])
+ }, [busy, clearOutput, file, hasTrim, options.muteAudio, persistOutput, trim])
const exportGif = useCallback(async () => {
if (!file || busy) return
@@ -244,15 +304,17 @@ export function useSendableVideoStore() {
await execWithLogs(ffmpeg, gifArgs(hasTrim ? trim : null, preset))
const blob = await readOutputBlob(ffmpeg, OUTPUT_GIF, 'image/gif')
+ const outputName = `${stem(file.name)}_${preset.fps}fps.gif`
+ persistOutput({ blob, name: outputName, mime: 'image/gif' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_${preset.fps}fps.gif`,
+ name: outputName,
size: blob.size,
kind: 'gif'
})
setProgress(100)
setStatus({
- message: 'Done — your GIF is ready to download.',
+ message: 'Done - your GIF is ready to download.',
mode: 'success'
})
@@ -264,7 +326,7 @@ export function useSendableVideoStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, file, hasTrim, options.gifFps, trim])
+ }, [busy, clearOutput, file, hasTrim, options.gifFps, persistOutput, trim])
return {
file,
@@ -279,7 +341,7 @@ export function useSendableVideoStore() {
busy,
output,
selectFile,
- handleMetadata: trimState.handleMetadata,
+ handleMetadata,
updateTrim: trimState.updateTrim,
commitTrim: trimState.commitTrim,
undoTrim: trimState.undoTrim,
diff --git a/src/features/miscellaneous/sendable-video/index.tsx b/src/features/miscellaneous/sendable-video/index.tsx
index a621e5f3..d9744ca0 100644
--- a/src/features/miscellaneous/sendable-video/index.tsx
+++ b/src/features/miscellaneous/sendable-video/index.tsx
@@ -1,5 +1,7 @@
'use client'
+import { X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
import { MediaDropzone } from '../components/media-dropzone'
import { MediaTrimPanel } from '../components/media-trim-panel'
import { ExportPanel } from './components/export-panel'
@@ -11,15 +13,30 @@ export default function SendableVideo() {
return (
-
+
+
+ {store.file ? (
+
+ ) : null}
+
{store.fileUrl ? (
{
+ const logs: string[] = []
+ const onLog = ({ message }: { message: string }) => {
+ logs.push(message)
+ }
+ ffmpeg.on('log', onLog)
+
+ try {
+ await ffmpeg.exec(['-i', name])
+ } catch {
+ noop()
+ } finally {
+ ffmpeg.off('log', onLog)
+ }
+
+ return logs.join('\n')
+}
+
/**
* Writes a browser File into the ffmpeg virtual filesystem under the given
* name, replacing any previous file with that name.
diff --git a/src/features/miscellaneous/utils/media-persistence.ts b/src/features/miscellaneous/utils/media-persistence.ts
new file mode 100644
index 00000000..f76db9cb
--- /dev/null
+++ b/src/features/miscellaneous/utils/media-persistence.ts
@@ -0,0 +1,152 @@
+'use client'
+
+import type { TTrimRange } from '../types/media'
+
+const DB_NAME = 'media-tools'
+const STORE_NAME = 'sessions'
+const DB_VERSION = 1
+
+export type TPersistedOutput = {
+ blob: Blob
+ name: string
+ mime: string
+}
+
+export type TPersistedSession = {
+ file: File | null
+ trim: TTrimRange | null
+ output: TPersistedOutput | null
+}
+
+function inputKey(toolKey: string): string {
+ return `${toolKey}:input`
+}
+
+function trimKey(toolKey: string): string {
+ return `${toolKey}:trim`
+}
+
+function outputKey(toolKey: string): string {
+ return `${toolKey}:output`
+}
+
+function openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = window.indexedDB.open(DB_NAME, DB_VERSION)
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
+ request.result.createObjectStore(STORE_NAME)
+ }
+ }
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+}
+
+async function readKey(key: string): Promise {
+ const db = await openDatabase()
+ try {
+ return await new Promise((resolve, reject) => {
+ const request = db
+ .transaction(STORE_NAME)
+ .objectStore(STORE_NAME)
+ .get(key)
+ request.onsuccess = () => resolve(request.result as T | undefined)
+ request.onerror = () => reject(request.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+async function writeKey(key: string, value: unknown): Promise {
+ const db = await openDatabase()
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
+ transaction.objectStore(STORE_NAME).put(value, key)
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+async function deleteKeys(keys: string[]): Promise {
+ const db = await openDatabase()
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite')
+ const store = transaction.objectStore(STORE_NAME)
+ for (const key of keys) store.delete(key)
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(transaction.error)
+ })
+ } finally {
+ db.close()
+ }
+}
+
+/**
+ * Loads a tool's persisted media session (input file, trim range, output)
+ * from IndexedDB. Missing pieces come back as null.
+ */
+export async function loadMediaSession(
+ toolKey: string
+): Promise {
+ const [file, trim, output] = await Promise.all([
+ readKey(inputKey(toolKey)),
+ readKey(trimKey(toolKey)),
+ readKey(outputKey(toolKey))
+ ])
+ return { file: file ?? null, trim: trim ?? null, output: output ?? null }
+}
+
+/**
+ * Persists a newly selected input file. Passing a file resets the stored trim
+ * and output (they belong to the previous file); passing null clears the
+ * whole session.
+ */
+export async function saveMediaFile(
+ toolKey: string,
+ file: File | null
+): Promise {
+ if (!file) {
+ await clearMediaSession(toolKey)
+ return
+ }
+ await deleteKeys([trimKey(toolKey), outputKey(toolKey)])
+ await writeKey(inputKey(toolKey), file)
+}
+
+/** Persists the current trim range, or clears it when null. */
+export async function saveMediaTrim(
+ toolKey: string,
+ trim: TTrimRange | null
+): Promise {
+ if (!trim) {
+ await deleteKeys([trimKey(toolKey)])
+ return
+ }
+ await writeKey(trimKey(toolKey), trim)
+}
+
+/** Persists the latest converted output, or clears it when null. */
+export async function saveMediaOutput(
+ toolKey: string,
+ output: TPersistedOutput | null
+): Promise {
+ if (!output) {
+ await deleteKeys([outputKey(toolKey)])
+ return
+ }
+ await writeKey(outputKey(toolKey), output)
+}
+
+/** Removes every persisted piece of a tool's media session. */
+export async function clearMediaSession(toolKey: string): Promise {
+ await deleteKeys([inputKey(toolKey), trimKey(toolKey), outputKey(toolKey)])
+}
diff --git a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
index 29e6c4a6..78169015 100644
--- a/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
+++ b/src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { noop } from '@/shared/lib/noop'
import { useLocalStorage } from '../../hooks/use-local-storage'
+import { useMediaSession } from '../../hooks/use-media-session'
import { useTrimState } from '../../hooks/use-trim-state'
import type {
TMediaOutput,
@@ -70,6 +71,31 @@ export function useVideoToGifStore() {
const inputWrittenFor = useRef(null)
const urlsRef = useRef([])
+ const pendingTrimRef = useRef(null)
+ const restoredRef = useRef(false)
+
+ const { persistFile, persistTrim, persistOutput } = useMediaSession(
+ STORAGE_KEY,
+ session => {
+ if (restoredRef.current) return
+ restoredRef.current = true
+ pendingTrimRef.current = session.trim
+ setFile(session.file)
+ setFileUrl(URL.createObjectURL(session.file))
+ if (session.output) {
+ setOutput({
+ url: URL.createObjectURL(session.output.blob),
+ name: session.output.name,
+ size: session.output.blob.size,
+ mime: session.output.mime
+ })
+ }
+ setStatus({
+ message: 'Restored your previous session from this browser.',
+ mode: 'idle'
+ })
+ }
+ )
useEffect(() => {
urlsRef.current = [fileUrl, preview?.url, output?.url].filter(
@@ -92,16 +118,19 @@ export function useVideoToGifStore() {
}, [])
const clearOutput = useCallback(() => {
+ persistOutput(null)
setOutput(previous => {
if (previous) URL.revokeObjectURL(previous.url)
return null
})
- }, [])
+ }, [persistOutput])
const selectFile = useCallback(
(next: File | null) => {
if (busy) return
+ restoredRef.current = true
+ pendingTrimRef.current = null
clearPreview()
clearOutput()
inputWrittenFor.current = null
@@ -113,6 +142,7 @@ export function useVideoToGifStore() {
setProgress(0)
if (!next) {
+ persistFile(null)
setFile(null)
setStatus(IDLE_STATUS)
return
@@ -120,6 +150,7 @@ export function useVideoToGifStore() {
const sizeMb = next.size / (1024 * 1024)
if (sizeMb > MAX_INPUT_MB) {
+ persistFile(null)
setFile(null)
setStatus({
message: `File is ${sizeMb.toFixed(1)} MB. Keep it under ${MAX_INPUT_MB} MB for browser conversion.`,
@@ -128,6 +159,7 @@ export function useVideoToGifStore() {
return
}
+ persistFile(next)
setFile(next)
setFileUrl(URL.createObjectURL(next))
setStatus({
@@ -136,9 +168,28 @@ export function useVideoToGifStore() {
mode: 'idle'
})
},
- [busy, clearOutput, clearPreview, clearTrim]
+ [busy, clearOutput, clearPreview, clearTrim, persistFile]
+ )
+
+ const handleMetadata = useCallback(
+ (nextDuration: number) => {
+ trimState.handleMetadata(
+ nextDuration,
+ pendingTrimRef.current ?? undefined
+ )
+ pendingTrimRef.current = null
+ },
+ [trimState.handleMetadata]
)
+ useEffect(() => {
+ if (!file || duration <= 0) return
+ const timeout = setTimeout(() => {
+ persistTrim(hasTrim ? trim : null)
+ }, 300)
+ return () => clearTimeout(timeout)
+ }, [file, duration, hasTrim, trim, persistTrim])
+
const updateTrim = useCallback(
(next: TTrimRange) => {
trimState.updateTrim(next)
@@ -285,9 +336,11 @@ export function useVideoToGifStore() {
)
const blob = await readOutputBlob(ffmpeg, OUTPUT_NAME, 'image/gif')
+ const outputName = `${stem(file.name)}_${options.fps}fps.gif`
+ persistOutput({ blob, name: outputName, mime: 'image/gif' })
setOutput({
url: URL.createObjectURL(blob),
- name: `${stem(file.name)}_${options.fps}fps.gif`,
+ name: outputName,
size: blob.size,
mime: 'image/gif'
})
@@ -304,7 +357,16 @@ export function useVideoToGifStore() {
setFFmpegProgressHandler(noop)
setBusy(false)
}
- }, [busy, clearOutput, ensureInputWritten, file, hasTrim, options, trim])
+ }, [
+ busy,
+ clearOutput,
+ ensureInputWritten,
+ file,
+ hasTrim,
+ options,
+ persistOutput,
+ trim
+ ])
const effectiveDuration = hasTrim ? trim.end - trim.start : duration
const estimatedSize =
@@ -327,7 +389,7 @@ export function useVideoToGifStore() {
output,
estimatedSize,
selectFile,
- handleMetadata: trimState.handleMetadata,
+ handleMetadata,
updateTrim,
commitTrim: trimState.commitTrim,
undoTrim,
diff --git a/src/features/packages/data.ts b/src/features/packages/data.ts
index 67396dfe..fbf17552 100644
--- a/src/features/packages/data.ts
+++ b/src/features/packages/data.ts
@@ -31,7 +31,6 @@ export type DeveloperPackage = {
code: string
fileName: string
}[]
- faqs: { question: string; answer: string }[]
}
export const developerPackages: readonly DeveloperPackage[] = [
@@ -264,28 +263,6 @@ export const adapter = createAdapter({
}
})`
}
- ],
- faqs: [
- {
- question: 'Does it include an auth backend?',
- answer: 'No, deliberately. It is the sign-in surface plus a typed adapter over whatever already owns your sessions: Better Auth, Supabase, NextAuth, Clerk, Firebase, Passport, or your own JWT and REST endpoints. Your backend does not change; the drawer just stops you from rebuilding its UI in every app.'
- },
- {
- question: 'My auth provider is not in the adapter list. Am I stuck?',
- answer: 'No. Implement the exported AuthAdapter contract (signIn, signUp, signOut, useSession) and the drawer treats it like any first-party adapter. TypeScript checks your implementation against the contract, and the UI reveals registration, OAuth, and reset flows based on which methods you actually provide.'
- },
- {
- question: 'Do I need Tailwind or a separate stylesheet?',
- answer: 'Neither. Prebuilt styles ship with the component import, so there is no CSS file to remember and no Tailwind requirement in your app. Presentation, copy, provider buttons, and motion are all shaped through one deep-merged config object instead.'
- },
- {
- question: 'Does it work with the Next.js App Router?',
- answer: 'Yes. Mount AuthProvider and AuthDrawer in a small client shell near the root and add a portal div to your layout so the drawer renders above page content with scroll lock. Server components everywhere else stay server components.'
- },
- {
- question: 'Can I build the UI before the backend exists?',
- answer: 'Yes. Ship the bundled mock adapter, click through sign-in, registration, and reset with fake sessions, then swap in the real adapter later. Nothing in the UI layer changes.'
- }
]
},
{
@@ -439,16 +416,6 @@ $.mod.key('k').on(() => openPalette())`
}
]
}
- ],
- faqs: [
- {
- question: 'Does mod work on Windows and macOS?',
- answer: 'Yes. The mod modifier maps to Cmd on macOS and Ctrl elsewhere.'
- },
- {
- question: 'Can shortcuts be scoped to an editor or modal?',
- answer: 'Yes. Use scopes and guards so only active UI owns a shortcut.'
- }
]
},
{
@@ -603,16 +570,6 @@ saveSettings().then(() => notice.success('Settings saved'))`
}
]
}
- ],
- faqs: [
- {
- question: 'Can I use promise tracking?',
- answer: 'Yes. notify.promise() manages loading, success, and error states around async work.'
- },
- {
- question: 'Can notifications be themed?',
- answer: 'Yes. Set color mode, radius, icon treatment, position, duration, and swipe behavior on Notifier.'
- }
]
}
]
From 6eb58bc4c56cc036ab3a03ee088e444e7d9ed42e Mon Sep 17 00:00:00 2001
From: Remco Stoeten
Date: Sat, 1 Aug 2026 23:16:39 +0200
Subject: [PATCH 4/4] fix: eliminate layout shift on tools pages
- render breadcrumbs server-side by scoping the useSearchParams
Suspense boundary to individual links instead of the whole nav
- make the /tools/[slug] Suspense fallback mirror the real page
chrome, including the actual static ToolQuickNav
- replace the single generic tool skeleton with per-tool skeletons
that reproduce each tool's first-paint layout at all breakpoints
- reuse those skeletons for the internal hydration gates of
find-replace, diff-checker, hemelsbreed and coordinate-marker so
chunk-load, hydration and ready states share one stable frame
---
src/app/(tools)/tools/[slug]/page.tsx | 16 +-
src/components/layout/breadcrumbs.tsx | 52 ++-
.../components/tool-renderer.tsx | 82 ++--
.../components/tool-skeletons.tsx | 383 ++++++++++++++++++
.../miscellaneous/coordinate-marker/index.tsx | 12 +-
.../miscellaneous/diff-checker/index.tsx | 13 +-
.../miscellaneous/find-replace/index.tsx | 13 +-
.../miscellaneous/hemelsbreed/index.tsx | 12 +-
8 files changed, 493 insertions(+), 90 deletions(-)
create mode 100644 src/features/miscellaneous/components/tool-skeletons.tsx
diff --git a/src/app/(tools)/tools/[slug]/page.tsx b/src/app/(tools)/tools/[slug]/page.tsx
index 80937fdd..f280ce6c 100644
--- a/src/app/(tools)/tools/[slug]/page.tsx
+++ b/src/app/(tools)/tools/[slug]/page.tsx
@@ -99,14 +99,20 @@ async function ToolPage({ params }: Props) {
function ToolPageFallback() {
return (
)
}
diff --git a/src/components/layout/breadcrumbs.tsx b/src/components/layout/breadcrumbs.tsx
index 5449a06c..2e9a9e75 100644
--- a/src/components/layout/breadcrumbs.tsx
+++ b/src/components/layout/breadcrumbs.tsx
@@ -57,14 +57,42 @@ function generateBreadcrumbs(pathname: string): BreadcrumbItem[] {
return breadcrumbs
}
-function BreadcrumbsContent({ params }: BreadcrumbProps) {
- const pathname = usePathname()
+function CrumbLinkWithLang({ href, label }: { href: string; label: string }) {
const searchParams = useSearchParams()
- const breadcrumbs = generateBreadcrumbs(pathname)
-
const langParam = searchParams.get('lang')
const linkParams = langParam ? `?lang=${langParam}` : ''
+ return (
+
+ {label}
+
+ )
+}
+
+function CrumbLink({ href, label }: { href: string; label: string }) {
+ return (
+
+ {label}
+
+ }
+ >
+
+
+ )
+}
+
+function BreadcrumbsContent({ params }: BreadcrumbProps) {
+ const pathname = usePathname()
+ const breadcrumbs = generateBreadcrumbs(pathname)
+
if (pathname === '/' || breadcrumbs.length === 0) {
return null
}
@@ -94,12 +122,10 @@ function BreadcrumbsContent({ params }: BreadcrumbProps) {
{crumb.label.toLowerCase()}
) : (
-
- {crumb.label.toLowerCase()}
-
+
)}
@@ -110,9 +136,5 @@ function BreadcrumbsContent({ params }: BreadcrumbProps) {
}
export function Breadcrumbs(props: BreadcrumbProps) {
- return (
-
-
-
- )
+ return
}
diff --git a/src/features/miscellaneous/components/tool-renderer.tsx b/src/features/miscellaneous/components/tool-renderer.tsx
index dce6a0e3..6f23d90d 100644
--- a/src/features/miscellaneous/components/tool-renderer.tsx
+++ b/src/features/miscellaneous/components/tool-renderer.tsx
@@ -1,41 +1,67 @@
'use client'
import nextDynamic from 'next/dynamic'
-import type { ComponentType } from 'react'
+import type { ComponentType, ReactNode } from 'react'
import type { TToolSlug } from '../constants/tools'
-
-function ToolSkeleton() {
- return (
-
- )
-}
+import {
+ CoordinateMarkerSkeleton,
+ DiffCheckerSkeleton,
+ FindReplaceSkeleton,
+ GifToVideoSkeleton,
+ HemelsbreedSkeleton,
+ JsonToolSkeleton,
+ LinkExtractorSkeleton,
+ MyLocationSkeleton,
+ SendableVideoSkeleton,
+ SvgConverterSkeleton,
+ VideoToGifSkeleton
+} from './tool-skeletons'
type TLoader = () => Promise<{ default: ComponentType }>
-function lazyTool(loader: TLoader, ssr = false) {
- return nextDynamic(loader, { ssr, loading: ToolSkeleton })
+function lazyTool(loader: TLoader, skeleton: () => ReactNode, ssr = false) {
+ return nextDynamic(loader, { ssr, loading: skeleton })
}
const TOOL_COMPONENTS: Record = {
- 'find-replace': lazyTool(() => import('../find-replace')),
- 'diff-checker': lazyTool(() => import('../diff-checker'), true),
- 'link-extractor': lazyTool(() => import('../link-extractor'), true),
- 'json-tool': lazyTool(() => import('../json-tool'), true),
- 'svg-converter': lazyTool(() => import('../svg-converter'), true),
- hemelsbreed: lazyTool(() => import('../hemelsbreed')),
- 'coordinate-marker': lazyTool(() => import('../coordinate-marker')),
- 'my-location': lazyTool(() => import('../my-location')),
- 'sendable-video': lazyTool(() => import('../sendable-video')),
- 'gif-to-video': lazyTool(() => import('../gif-to-video')),
- 'video-to-gif': lazyTool(() => import('../video-to-gif'))
+ 'find-replace': lazyTool(
+ () => import('../find-replace'),
+ FindReplaceSkeleton
+ ),
+ 'diff-checker': lazyTool(
+ () => import('../diff-checker'),
+ DiffCheckerSkeleton,
+ true
+ ),
+ 'link-extractor': lazyTool(
+ () => import('../link-extractor'),
+ LinkExtractorSkeleton,
+ true
+ ),
+ 'json-tool': lazyTool(() => import('../json-tool'), JsonToolSkeleton, true),
+ 'svg-converter': lazyTool(
+ () => import('../svg-converter'),
+ SvgConverterSkeleton,
+ true
+ ),
+ hemelsbreed: lazyTool(() => import('../hemelsbreed'), HemelsbreedSkeleton),
+ 'coordinate-marker': lazyTool(
+ () => import('../coordinate-marker'),
+ CoordinateMarkerSkeleton
+ ),
+ 'my-location': lazyTool(() => import('../my-location'), MyLocationSkeleton),
+ 'sendable-video': lazyTool(
+ () => import('../sendable-video'),
+ SendableVideoSkeleton
+ ),
+ 'gif-to-video': lazyTool(
+ () => import('../gif-to-video'),
+ GifToVideoSkeleton
+ ),
+ 'video-to-gif': lazyTool(
+ () => import('../video-to-gif'),
+ VideoToGifSkeleton
+ )
}
type Props = {
diff --git a/src/features/miscellaneous/components/tool-skeletons.tsx b/src/features/miscellaneous/components/tool-skeletons.tsx
new file mode 100644
index 00000000..12e65d79
--- /dev/null
+++ b/src/features/miscellaneous/components/tool-skeletons.tsx
@@ -0,0 +1,383 @@
+import { cn } from '@/shared/lib/cn'
+
+type TBlockProps = {
+ className?: string
+}
+
+function Block({ className }: TBlockProps) {
+ return
+}
+
+function PanelHeader({ className }: TBlockProps) {
+ return (
+
+
+
+
+ )
+}
+
+function PanelFooter() {
+ return (
+
+
+
+ )
+}
+
+export function FindReplaceSkeleton() {
+ return (
+
+ )
+}
+
+export function DiffCheckerSkeleton() {
+ return (
+
+ )
+}
+
+export function LinkExtractorSkeleton() {
+ return (
+
+ )
+}
+
+export function JsonToolSkeleton() {
+ return (
+
+ )
+}
+
+export function SvgConverterSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function HemelsbreedSkeleton() {
+ return (
+
+ )
+}
+
+export function CoordinateMarkerSkeleton() {
+ return (
+
+ )
+}
+
+export function MyLocationSkeleton() {
+ return (
+
+ )
+}
+
+function DropzoneSkeleton() {
+ return (
+
+
+
+
+
+ )
+}
+
+export function SendableVideoSkeleton() {
+ return (
+
+ )
+}
+
+export function GifToVideoSkeleton() {
+ return (
+
+ )
+}
+
+export function VideoToGifSkeleton() {
+ return (
+
+ )
+}
diff --git a/src/features/miscellaneous/coordinate-marker/index.tsx b/src/features/miscellaneous/coordinate-marker/index.tsx
index 6ec8d89b..b74f3e98 100644
--- a/src/features/miscellaneous/coordinate-marker/index.tsx
+++ b/src/features/miscellaneous/coordinate-marker/index.tsx
@@ -15,6 +15,7 @@ import { noop } from '@/shared/lib/noop'
import 'leaflet/dist/leaflet.css'
import type * as L from 'leaflet'
import { SendToTool } from '../components/send-to-tool'
+import { CoordinateMarkerSkeleton } from '../components/tool-skeletons'
import { geolocationErrorMessage, locate } from '../utils/geolocation'
import {
consumeLocations,
@@ -405,16 +406,7 @@ export default function CoordinateMarkerTool() {
)
if (!ready) {
- return (
-
- )
+ return
}
return (
diff --git a/src/features/miscellaneous/diff-checker/index.tsx b/src/features/miscellaneous/diff-checker/index.tsx
index 5667aaad..4e8b1b00 100644
--- a/src/features/miscellaneous/diff-checker/index.tsx
+++ b/src/features/miscellaneous/diff-checker/index.tsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import { ArrowLeftRight, Eraser } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
+import { DiffCheckerSkeleton } from '../components/tool-skeletons'
import { useLocalStorage } from '../hooks/use-local-storage'
import { DiffOutput } from './components/diff-output'
import { TextPanel } from './components/text-panel'
@@ -34,17 +35,7 @@ export default function DiffCheckerTool() {
}, [leftHydrated, rightHydrated, setLeft, setRight])
if (!leftHydrated || !rightHydrated) {
- return (
-
- )
+ return
}
function swap() {
diff --git a/src/features/miscellaneous/find-replace/index.tsx b/src/features/miscellaneous/find-replace/index.tsx
index e39e6cdd..b737cade 100644
--- a/src/features/miscellaneous/find-replace/index.tsx
+++ b/src/features/miscellaneous/find-replace/index.tsx
@@ -19,6 +19,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
+import { FindReplaceSkeleton } from '../components/tool-skeletons'
import { writeDiffHandoff } from '../diff-checker/utils/handoff'
import { AdvancedReplace } from './components/advanced-replace'
import { EditorPanels } from './components/editor-panels'
@@ -165,17 +166,7 @@ export default function FindReplaceTool() {
}
if (!store.hydrated) {
- return (
-
- )
+ return
}
return (
diff --git a/src/features/miscellaneous/hemelsbreed/index.tsx b/src/features/miscellaneous/hemelsbreed/index.tsx
index ad676496..e8de47c3 100644
--- a/src/features/miscellaneous/hemelsbreed/index.tsx
+++ b/src/features/miscellaneous/hemelsbreed/index.tsx
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/shared/lib/cn'
import { useLocalStorage } from '../hooks/use-local-storage'
import { SendToTool } from '../components/send-to-tool'
+import { HemelsbreedSkeleton } from '../components/tool-skeletons'
import { geolocationErrorMessage, locate } from '../utils/geolocation'
import { consumeLocations, type TLocationPoint } from '../utils/location-handoff'
import { appendSavedLocation, locationLabel } from '../utils/locations'
@@ -396,16 +397,7 @@ export default function HemelsbreedTool() {
}
if (!hydrated) {
- return (
-
- )
+ return
}
return (