fix: make toolbar, sidebar, and lightbox responsive on small screens

This commit is contained in:
2026-06-28 21:12:22 +01:00
parent 86a1a53289
commit f65fd350cc
4 changed files with 86 additions and 72 deletions
+65 -61
View File
@@ -56,7 +56,7 @@ export function ColorFilter() {
}, [open]); }, [open]);
return ( return (
<div ref={ref} className="ml-1 flex items-center border-l border-white/[0.06] pl-2"> <div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a {/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}> <Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
@@ -85,70 +85,74 @@ export function ColorFilter() {
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{open ? ( {open ? (
// Right-aligned popover so it never widens the toolbar row or gets
// pushed off-screen on narrow windows. Swatches wrap into a compact
// grid instead of a single long horizontal strip.
<motion.div <motion.div
initial={{ width: 0, opacity: 0 }} initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ width: "auto", opacity: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ width: 0, opacity: 0 }} exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }} transition={{ duration: 0.14, ease: "easeOut" }}
// Horizontal/vertical padding gives the active swatch's ring + scale className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50"
// room inside the overflow-hidden clip box (needed for the width slide).
// py-1 keeps the panel shorter than the always-present filter pills so
// opening it never changes the row height (no 1px toolbar shift).
className="flex items-center gap-1 overflow-hidden whitespace-nowrap px-1.5 py-1"
> >
{SWATCHES.map((swatch) => { <div className="grid grid-cols-7 gap-1.5">
const active = rgbEquals(colorFilter, swatch.rgb); {SWATCHES.map((swatch) => {
return ( const active = rgbEquals(colorFilter, swatch.rgb);
<button return (
key={swatch.name} <button
title={swatch.name} key={swatch.name}
aria-label={`Filter by ${swatch.name}`} title={swatch.name}
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${ aria-label={`Filter by ${swatch.name}`}
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
}`} active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
style={{ backgroundColor: toHex(swatch.rgb) }} }`}
onClick={() => setColorFilter(active ? null : swatch.rgb)} style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
);
})}
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
title="Custom color"
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/> />
); </label>
})} </div>
{/* Custom color picker — rainbow until a custom color is chosen. */} {isActive || (colorBackfill && colorBackfill.total > 0) ? (
<label <div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40">
title="Custom color" {colorBackfill && colorBackfill.total > 0 ? (
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${ <span
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" className="text-[10px] text-gray-600"
}`} title="Sampling colors from existing thumbnails — color search fills in as this runs"
style={ >
isCustom sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
? { backgroundColor: toHex(colorFilter as Rgb) } </span>
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" } ) : <span />}
} {isActive ? (
> <button
<input className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
type="color" onClick={() => setColorFilter(null)}
className="absolute inset-0 cursor-pointer opacity-0" title="Clear color filter"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"} >
onChange={(event) => setColorFilter(fromHex(event.target.value))} Clear
/> </button>
</label> ) : null}
</div>
{isActive ? (
<button
className="ml-0.5 shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
title="Clear color filter"
>
Clear
</button>
) : null}
{colorBackfill && colorBackfill.total > 0 ? (
<span
className="ml-0.5 shrink-0 text-[10px] text-gray-600"
title="Sampling colors from existing thumbnails — color search fills in as this runs"
>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
) : null} ) : null}
</motion.div> </motion.div>
) : null} ) : null}
+11 -4
View File
@@ -517,7 +517,7 @@ export function Lightbox() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="lightbox-panel flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95"> <div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4"> <div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p> <p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
@@ -534,7 +534,7 @@ export function Lightbox() {
</svg> </svg>
</button> </button>
<button <button
className={`rounded-full border px-3 py-1.5 text-xs ${ className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white" ? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed" : "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
@@ -544,8 +544,15 @@ export function Lightbox() {
void findSimilar(selectedImage.id, selectedImage.folder_id); void findSimilar(selectedImage.id, selectedImage.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
title={canFindSimilar ? "Find similar images" : "Embeddings not ready"}
> >
{canFindSimilar ? "Similar" : "Embeddings not ready"} <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" />
</svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
</button> </button>
</div> </div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}> <button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
@@ -949,7 +956,7 @@ export function Lightbox() {
</div> </div>
<button <button
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20" className="absolute right-72 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 lg:right-80"
disabled={currentIndex >= images.length - 1 || regionSelectMode} disabled={currentIndex >= images.length - 1 || regionSelectMode}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
+1 -1
View File
@@ -829,7 +829,7 @@ export function Sidebar() {
}; };
return ( return (
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]"> <aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0"> <div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span> <span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
+9 -6
View File
@@ -111,7 +111,7 @@ function FilterPill({
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"; : "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
return ( return (
<button <button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${ className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active active
? activeClass ? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
@@ -268,10 +268,10 @@ export function Toolbar() {
return ( return (
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl"> <div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */} {/* Primary row */}
<div className="flex items-center gap-3 px-5 h-12"> <div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */} {/* Title + count */}
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0"> <div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
<h2 className="text-[15px] font-semibold text-white truncate max-w-48">{title}</h2> <h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0"> <span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages {loadedCount < totalImages
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
@@ -322,7 +322,7 @@ export function Toolbar() {
}} }}
onFocus={() => setSearchPanelOpen(true)} onFocus={() => setSearchPanelOpen(true)}
placeholder="Search files, or use /s /t" placeholder="Search files, or use /s /t"
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`} className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/> />
{searchCommand !== null ? ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2"> <div className="absolute left-8 top-1/2 -translate-y-1/2">
@@ -452,8 +452,10 @@ export function Toolbar() {
</div> </div>
</div> </div>
{/* Filter row */} {/* Filter row — pills scroll horizontally on narrow widths so they never
squash or stack; the color filter stays pinned to the right. */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} /> <FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
@@ -481,8 +483,9 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)} onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/> />
) : null} ) : null}
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
<ColorFilter /> <ColorFilter />
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div> </div>
</div> </div>
); );