7673fc63ea
- Updated toolbar styles for improved layout and appearance, including padding and margin adjustments. - Introduced new styles for scale menu and compact dropdowns. - Enhanced map pin tooltips with better positioning and hover effects. - Added new styles for map sheet filters, including search and date range controls. - Improved editor styles for a more cohesive look and feel, including transport controls for video editing. - Expanded GeoLocation interface to include additional address fields and formatted address. - Updated MediaItem interface to include storage reference and uploadedBy fields. - Enhanced MediaComment interface with author details for better user identification. - Introduced GalleryUser interface to represent signed-in users in the host app.
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
import { useIsMobile } from '../hooks/useMediaQuery';
|
|
import { Icon, type IconName } from '../icons';
|
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
|
import type { Album, ViewId } from '../types';
|
|
import { closeContextMenu, openContextMenu } from './ContextMenu';
|
|
import { confirmAction, openSecuritySettings, promptAlbumName } from './modals';
|
|
|
|
interface RowProps {
|
|
icon: IconName;
|
|
label: string;
|
|
view?: ViewId;
|
|
trailing?: IconName;
|
|
indent?: boolean;
|
|
disclosure?: boolean;
|
|
open?: boolean;
|
|
/** Suppress active highlight (for duplicate rows that share a view). */
|
|
noActive?: boolean;
|
|
onToggle?: () => void;
|
|
onClick?: () => void;
|
|
onContextMenu?: (e: React.MouseEvent) => void;
|
|
/** When set, the trailing icon becomes its own clickable control. */
|
|
onTrailingClick?: () => void;
|
|
trailingLabel?: string;
|
|
}
|
|
|
|
function Row({
|
|
icon,
|
|
label,
|
|
view,
|
|
trailing,
|
|
indent,
|
|
disclosure,
|
|
open,
|
|
noActive,
|
|
onToggle,
|
|
onClick,
|
|
onContextMenu,
|
|
onTrailingClick,
|
|
trailingLabel,
|
|
}: RowProps) {
|
|
const api = useGalleryStoreApi();
|
|
const isMobile = useIsMobile();
|
|
const active = useGallery((s) => (view && !noActive ? s.view === view : false));
|
|
|
|
const handleClick = () => {
|
|
if (onClick) onClick();
|
|
else if (view) api.getState().setView(view);
|
|
if (isMobile) api.getState().setSidebar(false);
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={[
|
|
'apg-sidebar__item',
|
|
indent ? 'apg-sidebar__child' : '',
|
|
active ? 'apg-sidebar__item--active' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
onClick={handleClick}
|
|
onContextMenu={onContextMenu}
|
|
aria-current={active ? 'page' : undefined}
|
|
>
|
|
{disclosure ? (
|
|
<span
|
|
className={['apg-sidebar__disclosure', open ? 'apg-sidebar__disclosure--open' : ''].join(
|
|
' ',
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggle?.();
|
|
}}
|
|
>
|
|
<Icon name="chevron-right" size={13} />
|
|
</span>
|
|
) : null}
|
|
<span className="apg-sidebar__item-icon">
|
|
<Icon name={icon} size={17} />
|
|
</span>
|
|
<span className="apg-sidebar__item-label">{label}</span>
|
|
{trailing ? (
|
|
onTrailingClick ? (
|
|
<span
|
|
className="apg-sidebar__item-trail apg-sidebar__item-trail--btn"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={trailingLabel}
|
|
title={trailingLabel}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onTrailingClick();
|
|
}}
|
|
>
|
|
<Icon name={trailing} size={14} />
|
|
</span>
|
|
) : (
|
|
<span className="apg-sidebar__item-trail">
|
|
<Icon name={trailing} size={14} />
|
|
</span>
|
|
)
|
|
) : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return <div className="apg-sidebar__section-label">{children}</div>;
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const api = useGalleryStoreApi();
|
|
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
|
const albums = useGallery((s) => s.albums);
|
|
// Views the host asked to hide (e.g. Screenshots, Documents). A row is dropped
|
|
// when its `view` is listed; a section label is dropped when every row under it
|
|
// would be hidden.
|
|
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
|
const hidden = new Set<ViewId>(hiddenViews ?? []);
|
|
const shown = (view: ViewId) => !hidden.has(view);
|
|
const anyShown = (...views: ViewId[]) => views.some(shown);
|
|
// Recently Deleted lock state → closed lock when protected & not yet opened.
|
|
const locked = useGallery((s) => s.lockConfigured && !s.lockUnlocked);
|
|
const lockIcon: IconName = locked ? 'lock' : 'unlock';
|
|
const [sharingOpen, setSharingOpen] = useState(true);
|
|
const [albumsOpen, setAlbumsOpen] = useState(true);
|
|
const [objectsOpen, setObjectsOpen] = useState(true);
|
|
|
|
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
|
|
const toggleLock = () => {
|
|
const s = api.getState();
|
|
if (!s.lockConfigured) openSecuritySettings();
|
|
else if (s.lockUnlocked) s.relock();
|
|
else s.setView('recently-deleted');
|
|
};
|
|
|
|
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
|
|
// Auto "one album per detected object" (chair, table, person, car…), sorted by count.
|
|
const objectAlbums = albums.filter((a) => a.id.startsWith('sys:obj:'));
|
|
|
|
const albumMenu = (album: Album) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
openContextMenu(e.clientX, e.clientY, [
|
|
{
|
|
label: 'Rename Album',
|
|
icon: 'collections',
|
|
onClick: () =>
|
|
promptAlbumName('Rename Album', album.name, (name) =>
|
|
api.getState().renameAlbum(album.id, name),
|
|
),
|
|
},
|
|
{
|
|
label: 'Duplicate Album',
|
|
icon: 'duplicates',
|
|
onClick: () => api.getState().duplicateAlbum(album.id),
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: 'Delete Album',
|
|
icon: 'trash',
|
|
danger: true,
|
|
onClick: () => api.getState().deleteAlbum(album.id),
|
|
},
|
|
]);
|
|
};
|
|
|
|
// Right-click an auto object album → permanently rename its tag (e.g. car → excavator).
|
|
const objectMenu = (album: Album) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const label = album.id.slice('sys:obj:'.length);
|
|
openContextMenu(e.clientX, e.clientY, [
|
|
{
|
|
label: 'Rename Tag',
|
|
icon: 'tag',
|
|
onClick: () =>
|
|
promptAlbumName(
|
|
'Rename Tag',
|
|
album.name,
|
|
(name) => api.getState().renameLabel(label, name),
|
|
{ placeholder: 'Tag name' },
|
|
),
|
|
},
|
|
{
|
|
label: 'Delete Tag',
|
|
icon: 'trash',
|
|
danger: true,
|
|
onClick: () =>
|
|
confirmAction({
|
|
title: 'Delete Tag',
|
|
message: `Remove the "${album.name}" tag? It's deleted from all photos and won't be created again.`,
|
|
confirmLabel: 'Delete',
|
|
danger: true,
|
|
onConfirm: () => api.getState().deleteLabel(label),
|
|
}),
|
|
},
|
|
]);
|
|
};
|
|
|
|
const newAlbum = () =>
|
|
promptAlbumName('New Album', '', (name) => {
|
|
const id = api.getState().createAlbum(name);
|
|
api.getState().setView(`album:${id}` as ViewId);
|
|
});
|
|
|
|
return (
|
|
<nav
|
|
className={['apg-sidebar', sidebarOpen ? '' : 'apg-sidebar--collapsed'].join(' ')}
|
|
aria-label="Library navigation"
|
|
>
|
|
<Row icon="library" label="Library" view="library" />
|
|
<Row icon="collections" label="Collections" view="collections" />
|
|
|
|
{anyShown(
|
|
'favourites',
|
|
'recently-saved',
|
|
'map',
|
|
'videos',
|
|
'screenshots',
|
|
'sys:documents',
|
|
'people',
|
|
'recently-deleted',
|
|
) ? (
|
|
<SectionLabel>Pinned</SectionLabel>
|
|
) : null}
|
|
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
|
|
{shown('recently-saved') ? (
|
|
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
|
) : null}
|
|
{shown('map') ? <Row icon="map" label="Map" view="map" /> : null}
|
|
{shown('videos') ? <Row icon="video" label="Videos" view="videos" /> : null}
|
|
{shown('screenshots') ? (
|
|
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
|
) : null}
|
|
{shown('sys:documents') ? (
|
|
<Row icon="document" label="Documents" view="sys:documents" />
|
|
) : null}
|
|
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
|
|
{shown('recently-deleted') ? (
|
|
<Row
|
|
icon="trash"
|
|
label="Recently Deleted"
|
|
view="recently-deleted"
|
|
trailing={lockIcon}
|
|
onTrailingClick={toggleLock}
|
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
|
/>
|
|
) : null}
|
|
|
|
<div
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
openContextMenu(e.clientX, e.clientY, [
|
|
{ label: 'New Album', icon: 'collections', onClick: newAlbum },
|
|
]);
|
|
}}
|
|
>
|
|
<SectionLabel>Albums</SectionLabel>
|
|
</div>
|
|
<Row
|
|
icon="collections"
|
|
label="All Albums"
|
|
view="albums"
|
|
disclosure
|
|
open={albumsOpen}
|
|
onToggle={() => {
|
|
setAlbumsOpen((v) => !v);
|
|
closeContextMenu();
|
|
}}
|
|
/>
|
|
{albumsOpen ? (
|
|
<>
|
|
<Row icon="plus" label="New Album" onClick={newAlbum} indent />
|
|
{userAlbums.map((a) => (
|
|
<Row
|
|
key={a.id}
|
|
icon={a.kind === 'folder' ? 'folder' : 'image'}
|
|
label={a.name}
|
|
view={a.id as ViewId}
|
|
indent
|
|
onContextMenu={albumMenu(a)}
|
|
/>
|
|
))}
|
|
</>
|
|
) : null}
|
|
|
|
{objectAlbums.length ? (
|
|
<>
|
|
<SectionLabel>Objects</SectionLabel>
|
|
<Row
|
|
icon="tag"
|
|
label="All Objects"
|
|
view="collections"
|
|
disclosure
|
|
open={objectsOpen}
|
|
onToggle={() => {
|
|
setObjectsOpen((v) => !v);
|
|
closeContextMenu();
|
|
}}
|
|
/>
|
|
{objectsOpen
|
|
? objectAlbums.map((a) => (
|
|
<Row
|
|
key={a.id}
|
|
icon={(a.icon as IconName) ?? 'tag'}
|
|
label={a.name}
|
|
view={a.id as ViewId}
|
|
indent
|
|
onContextMenu={objectMenu(a)}
|
|
/>
|
|
))
|
|
: null}
|
|
</>
|
|
) : null}
|
|
|
|
<SectionLabel>Sharing</SectionLabel>
|
|
<Row
|
|
icon="people"
|
|
label="Shared Albums"
|
|
view="shared-albums"
|
|
disclosure
|
|
open={sharingOpen}
|
|
onToggle={() => {
|
|
setSharingOpen((v) => !v);
|
|
closeContextMenu();
|
|
}}
|
|
/>
|
|
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
|
|
|
|
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
|
|
<SectionLabel>Utilities</SectionLabel>
|
|
) : null}
|
|
{shown('recently-deleted') ? (
|
|
<Row
|
|
icon="trash"
|
|
label="Recently Deleted"
|
|
view="recently-deleted"
|
|
trailing={lockIcon}
|
|
noActive
|
|
onTrailingClick={toggleLock}
|
|
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
|
/>
|
|
) : null}
|
|
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
|
|
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
|
|
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
|
|
</nav>
|
|
);
|
|
}
|