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.
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
|
||
import { downloadMedia } from '../lib/download';
|
||
import { Icon } from '../icons';
|
||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||
import type { AlbumId, MediaId, ShareRecord, ViewId } from '../types';
|
||
import { closeModal, openModal } from './Modal';
|
||
|
||
/* ----------------------------- Rename / name prompt ----------------------------- */
|
||
|
||
function NamePrompt({
|
||
title,
|
||
initial,
|
||
confirmLabel,
|
||
placeholder,
|
||
onConfirm,
|
||
}: {
|
||
title: string;
|
||
initial: string;
|
||
confirmLabel: string;
|
||
placeholder?: string;
|
||
onConfirm: (name: string) => void;
|
||
}) {
|
||
const [value, setValue] = useState(initial);
|
||
const submit = () => {
|
||
const trimmed = value.trim();
|
||
if (trimmed) onConfirm(trimmed);
|
||
closeModal();
|
||
};
|
||
return (
|
||
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={title}>
|
||
<div className="apg-modal__title">{title}</div>
|
||
<input
|
||
className="apg-modal__input"
|
||
autoFocus
|
||
value={value}
|
||
maxLength={120}
|
||
placeholder={placeholder ?? 'Album name'}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') submit();
|
||
}}
|
||
/>
|
||
<div className="apg-modal__actions">
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Cancel
|
||
</button>
|
||
<button type="button" className="apg-btn apg-btn--primary" onClick={submit}>
|
||
{confirmLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function promptAlbumName(
|
||
title: string,
|
||
initial: string,
|
||
onConfirm: (name: string) => void,
|
||
opts?: { placeholder?: string; confirmLabel?: string },
|
||
) {
|
||
const confirmLabel =
|
||
opts?.confirmLabel ?? (title.toLowerCase().includes('rename') ? 'Rename' : 'Create');
|
||
openModal(
|
||
<NamePrompt
|
||
title={title}
|
||
initial={initial}
|
||
confirmLabel={confirmLabel}
|
||
placeholder={opts?.placeholder}
|
||
onConfirm={onConfirm}
|
||
/>,
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- Album picker ----------------------------- */
|
||
|
||
function AlbumPickerModal({
|
||
ids,
|
||
mode = 'copy',
|
||
fromAlbumId,
|
||
}: {
|
||
ids: MediaId[];
|
||
mode?: 'copy' | 'move';
|
||
fromAlbumId?: string;
|
||
}) {
|
||
const api = useGalleryStoreApi();
|
||
const albums = useGallery((s) =>
|
||
s.albums.filter((a) => (a.kind === 'user' || a.kind === 'folder') && a.id !== fromAlbumId),
|
||
);
|
||
const moving = mode === 'move' && !!fromAlbumId;
|
||
|
||
const place = (albumId: string) => {
|
||
if (moving) api.getState().moveToAlbum(fromAlbumId!, albumId, ids);
|
||
else api.getState().addToAlbum(albumId, ids);
|
||
closeModal();
|
||
};
|
||
|
||
const createAndPlace = () => {
|
||
closeModal();
|
||
promptAlbumName('New Album', '', (name) => {
|
||
const id = api.getState().createAlbum(name);
|
||
if (moving) api.getState().moveToAlbum(fromAlbumId!, id, ids);
|
||
else api.getState().addToAlbum(id, ids);
|
||
api.getState().setView(`album:${id}` as ViewId);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={moving ? 'Move to album' : 'Add to album'}>
|
||
<div className="apg-modal__title">
|
||
{moving ? 'Move' : 'Add'} {ids.length} item{ids.length === 1 ? '' : 's'} to…
|
||
</div>
|
||
<div className="apg-modal__list">
|
||
<button type="button" className="apg-modal__list-item" onClick={createAndPlace}>
|
||
+ New Album…
|
||
</button>
|
||
{albums.map((a) => (
|
||
<button key={a.id} type="button" className="apg-modal__list-item" onClick={() => place(a.id)}>
|
||
{a.name}
|
||
</button>
|
||
))}
|
||
{albums.length === 0 ? (
|
||
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>
|
||
No albums yet — create one above.
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="apg-modal__actions">
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function addToAlbumPicker(ids: MediaId[]) {
|
||
if (!ids.length) return;
|
||
openModal(<AlbumPickerModal ids={ids} />);
|
||
}
|
||
|
||
/** Move items out of `fromAlbumId` into a chosen album. */
|
||
export function moveToAlbumPicker(fromAlbumId: string, ids: MediaId[]) {
|
||
if (!ids.length) return;
|
||
openModal(<AlbumPickerModal ids={ids} mode="move" fromAlbumId={fromAlbumId} />);
|
||
}
|
||
|
||
/* ----------------------------- Confirm dialog ----------------------------- */
|
||
|
||
export function confirmAction(opts: {
|
||
title: string;
|
||
message: string;
|
||
confirmLabel?: string;
|
||
danger?: boolean;
|
||
onConfirm: () => void;
|
||
}) {
|
||
openModal(
|
||
<div className="apg-modal" role="alertdialog" aria-modal="true" aria-label={opts.title}>
|
||
<div className="apg-modal__title">{opts.title}</div>
|
||
<div className="apg-empty-card__text">{opts.message}</div>
|
||
<div className="apg-modal__actions">
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={['apg-btn', opts.danger ? '' : 'apg-btn--primary'].join(' ')}
|
||
style={opts.danger ? { background: 'var(--apg-danger)', color: '#fff' } : undefined}
|
||
onClick={() => {
|
||
closeModal();
|
||
opts.onConfirm();
|
||
}}
|
||
>
|
||
{opts.confirmLabel ?? 'Confirm'}
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
);
|
||
}
|
||
|
||
/* ----------------------------- Security (lock) settings ----------------------------- */
|
||
|
||
function SecuritySettingsModal() {
|
||
const api = useGalleryStoreApi();
|
||
const hasPw = useGallery((s) => s.lockConfigured);
|
||
// A server-backed lock reads/writes the host's backend, not this device.
|
||
const serverBacked = useGallery((s) => Boolean(s.config.lockProvider));
|
||
const [p1, setP1] = useState('');
|
||
const [p2, setP2] = useState('');
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const save = async () => {
|
||
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
||
if (p1 !== p2) return setErr('Passwords do not match.');
|
||
setBusy(true);
|
||
const state = api.getState();
|
||
state.clearLockError();
|
||
await state.setLockPassword(p1.trim());
|
||
setBusy(false);
|
||
// The store reports an unreachable provider rather than throwing.
|
||
if (api.getState().lockError === 'unavailable') {
|
||
setErr("Couldn't reach the server. Try again.");
|
||
return;
|
||
}
|
||
closeModal();
|
||
};
|
||
const remove = async () => {
|
||
setBusy(true);
|
||
const state = api.getState();
|
||
state.clearLockError();
|
||
await state.removeLockPassword();
|
||
setBusy(false);
|
||
if (api.getState().lockError === 'unavailable') {
|
||
setErr("Couldn't reach the server. Try again.");
|
||
return;
|
||
}
|
||
closeModal();
|
||
};
|
||
|
||
return (
|
||
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Recently Deleted lock">
|
||
<div className="apg-modal__title">
|
||
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
|
||
</div>
|
||
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
|
||
{serverBacked
|
||
? hasPw
|
||
? 'Set a new password, or remove the lock. It applies to your account on every device.'
|
||
: 'Protect Recently Deleted with a password. It applies to your account on every device.'
|
||
: hasPw
|
||
? 'Set a new password, or remove the lock. The password is stored only on this device.'
|
||
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
|
||
</div>
|
||
<input
|
||
className="apg-modal__input"
|
||
type="password"
|
||
autoFocus
|
||
placeholder={hasPw ? 'New password' : 'Password'}
|
||
value={p1}
|
||
onChange={(e) => {
|
||
setP1(e.target.value);
|
||
setErr(null);
|
||
}}
|
||
/>
|
||
<input
|
||
className="apg-modal__input"
|
||
type="password"
|
||
placeholder="Confirm password"
|
||
value={p2}
|
||
onChange={(e) => {
|
||
setP2(e.target.value);
|
||
setErr(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') void save();
|
||
}}
|
||
/>
|
||
{err ? <div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>{err}</div> : null}
|
||
<div className="apg-modal__actions">
|
||
{hasPw ? (
|
||
<button
|
||
type="button"
|
||
className="apg-btn"
|
||
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
|
||
disabled={busy}
|
||
onClick={() => void remove()}
|
||
>
|
||
Remove Lock
|
||
</button>
|
||
) : null}
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--primary"
|
||
disabled={busy}
|
||
onClick={() => void save()}
|
||
>
|
||
{hasPw ? 'Change' : 'Set Password'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function openSecuritySettings() {
|
||
openModal(<SecuritySettingsModal />);
|
||
}
|
||
|
||
/* ----------------------------- Share ----------------------------- */
|
||
|
||
function ShareModal({ selectionIds, albumId }: { selectionIds: MediaId[]; albumId?: AlbumId }) {
|
||
const api = useGalleryStoreApi();
|
||
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
|
||
const media = useGallery((s) => s.media);
|
||
const [picked, setPicked] = useState<Set<AlbumId>>(new Set(albumId ? [albumId] : []));
|
||
const [created, setCreated] = useState<ShareRecord | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const albumMediaIds = (id: AlbumId) =>
|
||
media.filter((m) => !m.deletedAt && m.albumIds.includes(id)).map((m) => m.id);
|
||
|
||
const finish = (share: ShareRecord) => {
|
||
setCreated(share);
|
||
void navigator.clipboard
|
||
?.writeText(share.url)
|
||
.then(() => setCopied(true))
|
||
.catch(() => setCopied(false));
|
||
};
|
||
|
||
const sharePhotos = () => {
|
||
finish(api.getState().createShare(selectionIds.length === 1 ? 'photo' : 'photos', selectionIds));
|
||
};
|
||
const shareAlbums = () => {
|
||
const ids = [...picked];
|
||
if (ids.length === 0) return;
|
||
const union = [...new Set(ids.flatMap(albumMediaIds))];
|
||
finish(api.getState().createShare('album', union, ids.length === 1 ? ids[0] : undefined));
|
||
};
|
||
const togglePick = (id: AlbumId) =>
|
||
setPicked((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
|
||
const download = () => {
|
||
const ids = created?.mediaIds ?? [];
|
||
for (const id of ids) {
|
||
const m = media.find((x) => x.id === id);
|
||
if (m) downloadMedia(m);
|
||
}
|
||
};
|
||
|
||
if (created) {
|
||
return (
|
||
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share link">
|
||
<div className="apg-modal__title">Link Ready</div>
|
||
<div className="apg-empty-card__text">
|
||
“{created.title}” — anyone with this link can view {created.mediaIds.length} item
|
||
{created.mediaIds.length === 1 ? '' : 's'}.
|
||
</div>
|
||
<input className="apg-modal__input" readOnly value={created.url} onFocus={(e) => e.currentTarget.select()} />
|
||
<div className="apg-modal__actions">
|
||
<button type="button" className="apg-btn" onClick={download}>
|
||
<Icon name="download" size={14} /> Download
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--primary"
|
||
onClick={() => {
|
||
void navigator.clipboard?.writeText(created.url).then(() => setCopied(true));
|
||
}}
|
||
>
|
||
{copied ? 'Copied ✓' : 'Copy Link'}
|
||
</button>
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share">
|
||
<div className="apg-modal__title">Share</div>
|
||
{selectionIds.length > 0 ? (
|
||
<button type="button" className="apg-modal__list-item" onClick={sharePhotos}>
|
||
<Icon name="image" size={15} />{' '}
|
||
{selectionIds.length === 1 ? 'This photo' : `${selectionIds.length} selected photos`}
|
||
</button>
|
||
) : null}
|
||
<div className="apg-empty-card__text" style={{ margin: '10px 2px 4px' }}>
|
||
Or share album{albums.length === 1 ? '' : 's'} (tick one or more):
|
||
</div>
|
||
<div className="apg-modal__list" style={{ maxHeight: 200 }}>
|
||
{albums.map((a) => (
|
||
<button
|
||
key={a.id}
|
||
type="button"
|
||
className="apg-modal__list-item"
|
||
onClick={() => togglePick(a.id)}
|
||
style={picked.has(a.id) ? { background: 'var(--apg-accent)', color: 'var(--apg-accent-contrast)' } : undefined}
|
||
>
|
||
<Icon name={picked.has(a.id) ? 'check' : 'collections'} size={15} /> {a.name}
|
||
</button>
|
||
))}
|
||
{albums.length === 0 ? (
|
||
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>No albums yet.</div>
|
||
) : null}
|
||
</div>
|
||
<div className="apg-modal__actions">
|
||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--primary"
|
||
disabled={picked.size === 0}
|
||
onClick={shareAlbums}
|
||
>
|
||
Share {picked.size > 0 ? `${picked.size} album${picked.size === 1 ? '' : 's'}` : 'Album'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Open the Share modal. Pass selected media ids and/or the current album id. */
|
||
export function openShareModal(selectionIds: MediaId[] = [], albumId?: AlbumId) {
|
||
openModal(<ShareModal selectionIds={selectionIds} albumId={albumId} />);
|
||
}
|