fix: make toolbar, sidebar, and lightbox responsive on small screens
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user