Compare commits
3 Commits
eb16564b84
...
6fa00f4b37
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fa00f4b37 | |||
| e49f64659e | |||
| 9317730885 |
@@ -266,6 +266,10 @@ export function HeapsPanel() {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
|
// Active-heap row gets a soft primary wash so the
|
||||||
|
// user always knows where Pick / T will land, even
|
||||||
|
// when viewing a different section.
|
||||||
|
isActive && !isFiltered && 'bg-primary/8 text-text',
|
||||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: '32px' }}
|
style={{ paddingLeft: '32px' }}
|
||||||
@@ -308,7 +312,7 @@ export function HeapsPanel() {
|
|||||||
<ShoppingBasket
|
<ShoppingBasket
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-4 w-4 flex-shrink-0',
|
'h-4 w-4 flex-shrink-0',
|
||||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -330,12 +334,23 @@ export function HeapsPanel() {
|
|||||||
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<span
|
<span
|
||||||
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
|
className={clsx('truncate', isActive && 'font-semibold')}
|
||||||
title={heap.name}
|
title={heap.name}
|
||||||
>
|
>
|
||||||
{heap.name}
|
{heap.name}
|
||||||
</span>
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
|
||||||
|
title="Active heap — Pick (P) and the basket badge on photos point here"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right cluster. Count is the rightmost element in the
|
{/* Right cluster. Count is the rightmost element in the
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
import { ShoppingBasket } from 'lucide-react'
|
|
||||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slim top bar — just the logo and the active-heap pill. The search input
|
* Slim top bar — just the logo. The active heap badge moved into the
|
||||||
* lives in the FilterBar now (next to the rest of the filter controls).
|
* Heaps panel in the left sidebar (where it actually relates to the
|
||||||
|
* heap rows the user navigates to).
|
||||||
*/
|
*/
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { data: heapsList = [] } = useHeapsQuery()
|
|
||||||
const activeHeap = heapsList.find((h) => h.is_active)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||||
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||||
</div>
|
</div>
|
||||||
{activeHeap && (
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
|
|
||||||
title="Active heap — press P to add selected photos here"
|
|
||||||
>
|
|
||||||
<ShoppingBasket className="h-3 w-3" />
|
|
||||||
{activeHeap.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2" />
|
<div className="flex items-center gap-2" />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interface PhotoThumbnailProps {
|
|||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
/** True when the photo belongs to the currently active heap. */
|
/** True when the photo belongs to the currently active heap. */
|
||||||
isInActiveHeap?: boolean
|
isInActiveHeap?: boolean
|
||||||
|
/** Name of the active heap. When set + isInActiveHeap, the basket
|
||||||
|
* badge expands into a name chip so the user knows which heap. */
|
||||||
|
activeHeapName?: string | null
|
||||||
onClick: (e: React.MouseEvent) => void
|
onClick: (e: React.MouseEvent) => void
|
||||||
onDoubleClick?: (e: React.MouseEvent) => void
|
onDoubleClick?: (e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ export function PhotoThumbnail({
|
|||||||
size,
|
size,
|
||||||
isSelected,
|
isSelected,
|
||||||
isInActiveHeap = false,
|
isInActiveHeap = false,
|
||||||
|
activeHeapName = null,
|
||||||
onClick,
|
onClick,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
}: PhotoThumbnailProps) {
|
}: PhotoThumbnailProps) {
|
||||||
@@ -118,8 +122,11 @@ export function PhotoThumbnail({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
||||||
'hover:ring-2 hover:ring-primary/50',
|
// Two-tone hover ring: bright primary inner + dark offset so it
|
||||||
isSelected && 'ring-2 ring-primary shadow-lg',
|
// pops on light AND dark photos.
|
||||||
|
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
|
||||||
|
isSelected &&
|
||||||
|
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
|
||||||
!imageLoaded && 'bg-surface animate-pulse'
|
!imageLoaded && 'bg-surface animate-pulse'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -181,49 +188,59 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selection Indicator */}
|
{/* Selection Indicator — white ring around the primary fill so the
|
||||||
|
* badge stays visible against any photo (light or dark). */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90">
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" strokeWidth={3} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rating Stars */}
|
{/* Rating Stars — same primary blue as every other indicator,
|
||||||
|
* in a matching pill so the rating reads as part of the same
|
||||||
|
* badge family instead of a yellow accent from a different
|
||||||
|
* palette. */}
|
||||||
{photo.rating > 0 && (
|
{photo.rating > 0 && (
|
||||||
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
<div className="absolute bottom-1 left-1 flex h-5 items-center gap-0.5 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90">
|
||||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||||
<Star
|
<Star key={i} className="h-3 w-3 fill-white text-white" />
|
||||||
key={i}
|
|
||||||
className="h-3 w-3 fill-star text-star"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flag Indicators */}
|
{/* Flag Indicators — all share the same blue-primary chip style
|
||||||
|
* + white ring so the family reads as one thing. The discard
|
||||||
|
* indicator is the only exception: it stays neutral-dark
|
||||||
|
* because mixing it with the positive blue chips would conflate
|
||||||
|
* "in this collection" with "trashed". */}
|
||||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||||
{isInActiveHeap && (
|
{isInActiveHeap && (
|
||||||
<div
|
<div
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
|
className="flex h-5 max-w-[120px] items-center gap-1 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90"
|
||||||
title="In active heap"
|
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
|
||||||
>
|
>
|
||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3 flex-shrink-0" strokeWidth={2.5} />
|
||||||
|
{activeHeapName && (
|
||||||
|
<span className="truncate text-[10px] font-medium leading-none">
|
||||||
|
{activeHeapName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.is_duplicate && (
|
{photo.is_duplicate && (
|
||||||
<div
|
<div
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90"
|
||||||
title="Duplicate (matches another photo's hash)"
|
title="Duplicate (matches another photo's hash)"
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.is_discarded && (
|
{photo.is_discarded && (
|
||||||
<div
|
<div
|
||||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-reject text-white shadow-md"
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/75 text-white shadow-md ring-2 ring-white/90"
|
||||||
title="Discarded"
|
title="Discarded"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export function Timeline() {
|
|||||||
// Membership in the active heap (for the basket affordance). Subscribed
|
// Membership in the active heap (for the basket affordance). Subscribed
|
||||||
// once at this level so we don't have hundreds of thumbnails each
|
// once at this level so we don't have hundreds of thumbnails each
|
||||||
// subscribing to the same query.
|
// subscribing to the same query.
|
||||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
|
||||||
|
const activeHeapName = activeHeap?.name ?? null
|
||||||
|
|
||||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||||
// photos. Date headers appear when sorted by a date field; tag headers
|
// photos. Date headers appear when sorted by a date field; tag headers
|
||||||
@@ -482,7 +483,7 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-end pb-1"
|
className="flex items-end pb-1"
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
{item.label}
|
{item.label}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,6 +511,7 @@ export function Timeline() {
|
|||||||
size={THUMBNAIL_SIZE}
|
size={THUMBNAIL_SIZE}
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
|
activeHeapName={activeHeapName}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
selectRange(photo.id)
|
selectRange(photo.id)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
--color-text: #e8e6e0;
|
--color-text: #e8e6e0;
|
||||||
--color-text-muted: #878580;
|
--color-text-muted: #878580;
|
||||||
--color-text-faint: #4a4845;
|
--color-text-faint: #4a4845;
|
||||||
--color-primary: #3b6ed8;
|
--color-primary: #3b82f6;
|
||||||
--color-pick: #4f9e5c;
|
--color-pick: #4f9e5c;
|
||||||
--color-reject: #c25a5a;
|
--color-reject: #c25a5a;
|
||||||
--color-star: #d4a340;
|
--color-star: #d4a340;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
text: '#e8e6e0',
|
text: '#e8e6e0',
|
||||||
'text-muted': '#878580',
|
'text-muted': '#878580',
|
||||||
'text-faint': '#4a4845',
|
'text-faint': '#4a4845',
|
||||||
primary: '#3b6ed8', // deep royal blue
|
primary: '#3b82f6', // saturated blue (Tailwind blue-500) — high contrast on dark bg AND on photo content
|
||||||
pick: '#4f9e5c', // green for picked
|
pick: '#4f9e5c', // green for picked
|
||||||
reject: '#c25a5a', // red for rejected
|
reject: '#c25a5a', // red for rejected
|
||||||
star: '#d4a340', // amber for stars
|
star: '#d4a340', // amber for stars
|
||||||
|
|||||||
Reference in New Issue
Block a user