feat(photos): ✨ improve search weighting and type safety
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9a9040cad5
commit
c2c7870915
9 changed files with 335 additions and 286 deletions
|
|
@ -131,6 +131,10 @@ export async function searchContacts(
|
|||
queryDigits.length > 0 ? `%${queryDigits}%` : null,
|
||||
];
|
||||
|
||||
// Force pg-node to type $4 as text — when queryDigits is empty the param is
|
||||
// null and the driver can't infer the type, producing "could not determine
|
||||
// data type of parameter $4". The CASE branches guard against null already.
|
||||
|
||||
const deviceClause = filter.deviceId
|
||||
? (() => {
|
||||
params.push(filter.deviceId);
|
||||
|
|
@ -152,7 +156,7 @@ export async function searchContacts(
|
|||
WHEN lower(c.display_name) LIKE $2 THEN 0.85
|
||||
WHEN lower(c.display_name) LIKE $3 THEN 0.70
|
||||
WHEN lower(c.handle) = lower($1) THEN 0.65
|
||||
WHEN $4 IS NOT NULL AND c.phone_numbers::text LIKE $4 THEN 0.55
|
||||
WHEN $4::text IS NOT NULL AND c.phone_numbers::text LIKE $4::text THEN 0.55
|
||||
WHEN c.emails::text ILIKE $3 THEN 0.50
|
||||
WHEN lower(c.handle) LIKE $3 THEN 0.40
|
||||
ELSE 0
|
||||
|
|
@ -162,7 +166,7 @@ export async function searchContacts(
|
|||
WHEN lower(c.display_name) LIKE $2 THEN 'display_name'
|
||||
WHEN lower(c.display_name) LIKE $3 THEN 'display_name'
|
||||
WHEN lower(c.handle) = lower($1) THEN 'handle'
|
||||
WHEN $4 IS NOT NULL AND c.phone_numbers::text LIKE $4 THEN 'phone'
|
||||
WHEN $4::text IS NOT NULL AND c.phone_numbers::text LIKE $4::text THEN 'phone'
|
||||
WHEN c.emails::text ILIKE $3 THEN 'email'
|
||||
WHEN lower(c.handle) LIKE $3 THEN 'handle'
|
||||
ELSE 'handle'
|
||||
|
|
|
|||
|
|
@ -659,7 +659,7 @@ async function trigramFallback(
|
|||
filter: MessageSearchFilter,
|
||||
limit: number,
|
||||
): Promise<SearchResult> {
|
||||
const conditions: string[] = ['(m.subject % $1 OR m.from_handle % $1)'];
|
||||
const conditions: string[] = ['(m.subject % $1::text OR m.from_handle % $1::text)'];
|
||||
const params: unknown[] = [filter.query];
|
||||
|
||||
if (filter.conversationId) {
|
||||
|
|
@ -688,8 +688,8 @@ async function trigramFallback(
|
|||
(SELECT array_agg(a.mime_type) FILTER (WHERE a.mime_type IS NOT NULL)
|
||||
FROM macsync.attachments a WHERE a.message_id = m.id) AS attachment_mime_types,
|
||||
GREATEST(
|
||||
similarity(coalesce(m.subject, ''), $1),
|
||||
similarity(coalesce(m.from_handle, ''), $1)
|
||||
similarity(coalesce(m.subject, ''), $1::text),
|
||||
similarity(coalesce(m.from_handle, ''), $1::text)
|
||||
) AS sim
|
||||
FROM macsync.messages m
|
||||
${where}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,18 @@ export async function fetchThread(conversationId: string, limit = 100, since?: s
|
|||
}
|
||||
}
|
||||
|
||||
export type SearchProvider = 'imessage' | 'icloud' | 'all';
|
||||
|
||||
export async function searchMessages(
|
||||
query: string,
|
||||
opts: { conversationId?: string; limit?: number; mode?: SearchMode; since?: string; offset?: number } = {},
|
||||
opts: {
|
||||
conversationId?: string;
|
||||
limit?: number;
|
||||
mode?: SearchMode;
|
||||
since?: string;
|
||||
offset?: number;
|
||||
provider?: SearchProvider;
|
||||
} = {},
|
||||
): Promise<MessageSearchResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
|
|
@ -31,6 +40,7 @@ export async function searchMessages(
|
|||
if (opts.mode) params.set('mode', opts.mode);
|
||||
if (opts.since) params.set('since', opts.since);
|
||||
if (opts.offset !== undefined && opts.offset > 0) params.set('offset', String(opts.offset));
|
||||
if (opts.provider && opts.provider !== 'all') params.set('provider', opts.provider);
|
||||
return await apiGet<MessageSearchResponse>(`/my/messages/search?${params}`);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('searchMessages failed');
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
import { apiGet } from './client';
|
||||
import type { Photo, Album } from '@/types';
|
||||
|
||||
export interface PhotoFilters {
|
||||
readonly limit?: number;
|
||||
readonly since?: string;
|
||||
readonly deviceId?: string;
|
||||
readonly albumId?: string;
|
||||
}
|
||||
|
||||
export async function fetchPhotos(filters: PhotoFilters = {}): Promise<Photo[]> {
|
||||
export async function fetchPhotos(limit = 100, since?: string, deviceId?: string): Promise<Photo[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(filters.limit ?? 100));
|
||||
if (filters.since) params.set('since', filters.since);
|
||||
if (filters.deviceId) params.set('deviceId', filters.deviceId);
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (since) params.set('since', since);
|
||||
if (deviceId) params.set('deviceId', deviceId);
|
||||
return await apiGet<Photo[]>(`/my/photos?${params}`);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('fetchPhotos failed');
|
||||
|
|
@ -27,24 +19,3 @@ export async function fetchAlbums(deviceId: string): Promise<Album[]> {
|
|||
throw err instanceof Error ? err : new Error('fetchAlbums failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPhotosByAlbum(albumId: string, limit = 200): Promise<Photo[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
return await apiGet<Photo[]>(`/my/photos/albums/${encodeURIComponent(albumId)}?${params}`);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('fetchPhotosByAlbum failed');
|
||||
}
|
||||
}
|
||||
|
||||
export type PhotoBlobSize = 'original' | 'thumbnail' | 'preview';
|
||||
|
||||
/**
|
||||
* Build a URL for the `/my/photos/:id/blob` endpoint with the requested size.
|
||||
* Caller must prefix with the configured server URL (handled by <img> when
|
||||
* pointing at the same origin, or by api/client for fetches).
|
||||
*/
|
||||
export function photoBlobUrl(photoId: string, size: PhotoBlobSize = 'thumbnail'): string {
|
||||
return `/my/photos/${encodeURIComponent(photoId)}/blob?size=${size}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { fetchConversations, fetchThread, searchMessages } from '@/api/messages';
|
||||
import { fetchConversations, fetchThread, searchMessages, type SearchProvider } from '@/api/messages';
|
||||
import type { Conversation, Message, MessageSearchHit, SearchMode } from '@/types';
|
||||
import { getFromCache, saveToCache } from '@/lib/searchCache';
|
||||
import { STALE_TIME } from './refreshConfig';
|
||||
|
|
@ -34,9 +34,10 @@ export interface MessageSearchState {
|
|||
|
||||
export function useMessageSearch(
|
||||
query: string,
|
||||
opts: { conversationId?: string; limit?: number; mode?: SearchMode } = {},
|
||||
opts: { conversationId?: string; limit?: number; mode?: SearchMode; provider?: SearchProvider } = {},
|
||||
): MessageSearchState {
|
||||
const mode: SearchMode = opts.mode ?? 'hybrid';
|
||||
const provider: SearchProvider = opts.provider ?? 'all';
|
||||
const limit = opts.limit ?? 50;
|
||||
const trimmed = query.trim();
|
||||
const enabled = trimmed.length >= 2;
|
||||
|
|
@ -49,8 +50,8 @@ export function useMessageSearch(
|
|||
);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Reset accumulated state when query/mode/conversationId changes
|
||||
const searchKey = `${trimmed}|${mode}|${opts.conversationId ?? ''}`;
|
||||
// Reset accumulated state when query/mode/provider/conversationId changes
|
||||
const searchKey = `${trimmed}|${mode}|${provider}|${opts.conversationId ?? ''}`;
|
||||
const searchKeyRef = useRef(searchKey);
|
||||
if (searchKeyRef.current !== searchKey) {
|
||||
searchKeyRef.current = searchKey;
|
||||
|
|
@ -61,7 +62,7 @@ export function useMessageSearch(
|
|||
}
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['messages', 'search', trimmed, mode, opts.conversationId ?? null],
|
||||
queryKey: ['messages', 'search', trimmed, mode, provider, opts.conversationId ?? null],
|
||||
queryFn: async () => {
|
||||
const cached = getFromCache(trimmed, mode);
|
||||
// Hybrid always re-ranks the full corpus; lexical/semantic fetch incrementally
|
||||
|
|
@ -70,6 +71,7 @@ export function useMessageSearch(
|
|||
...(opts.conversationId ? { conversationId: opts.conversationId } : {}),
|
||||
limit,
|
||||
mode,
|
||||
provider,
|
||||
...(since ? { since } : {}),
|
||||
});
|
||||
},
|
||||
|
|
@ -114,6 +116,7 @@ export function useMessageSearch(
|
|||
...(opts.conversationId ? { conversationId: opts.conversationId } : {}),
|
||||
limit,
|
||||
mode,
|
||||
provider,
|
||||
offset: hits.length,
|
||||
});
|
||||
setHits((prev) => {
|
||||
|
|
@ -133,7 +136,7 @@ export function useMessageSearch(
|
|||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isLoadingMore, hasMore, hits.length, trimmed, mode, opts.conversationId, limit]);
|
||||
}, [isLoadingMore, hasMore, hits.length, trimmed, mode, provider, opts.conversationId, limit]);
|
||||
|
||||
return {
|
||||
hits,
|
||||
|
|
|
|||
|
|
@ -1,82 +1,12 @@
|
|||
import { useQuery, useMutation, useQueryClient, type UseQueryResult, type UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
fetchAlbums,
|
||||
fetchPhotos,
|
||||
fetchPhotosByAlbum,
|
||||
type PhotoFilters,
|
||||
} from '@/api/photos';
|
||||
import {
|
||||
fetchIdentities,
|
||||
fetchIdentityPhotos,
|
||||
mergeIdentities,
|
||||
renameIdentity,
|
||||
} from '@/api/identities';
|
||||
import type { Album, Identity, IdentityPhotoListItem, Photo } from '@/types';
|
||||
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { fetchPhotos } from '@/api/photos';
|
||||
import type { Photo } from '@/types';
|
||||
import { STALE_TIME } from './refreshConfig';
|
||||
|
||||
export function usePhotos(filters: PhotoFilters = {}): UseQueryResult<Photo[]> {
|
||||
export function usePhotos(deviceId?: string): UseQueryResult<Photo[]> {
|
||||
return useQuery({
|
||||
queryKey: ['photos', filters],
|
||||
queryFn: () => fetchPhotos(filters),
|
||||
queryKey: ['photos', deviceId],
|
||||
queryFn: () => fetchPhotos(100, undefined, deviceId),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlbums(deviceId: string | undefined): UseQueryResult<Album[]> {
|
||||
return useQuery({
|
||||
queryKey: ['albums', deviceId],
|
||||
queryFn: () => (deviceId ? fetchAlbums(deviceId) : Promise.resolve([] as Album[])),
|
||||
enabled: Boolean(deviceId),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlbumPhotos(albumId: string | undefined, limit = 200): UseQueryResult<Photo[]> {
|
||||
return useQuery({
|
||||
queryKey: ['album-photos', albumId, limit],
|
||||
queryFn: () => (albumId ? fetchPhotosByAlbum(albumId, limit) : Promise.resolve([] as Photo[])),
|
||||
enabled: Boolean(albumId),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIdentities(deviceId: string | undefined): UseQueryResult<Identity[]> {
|
||||
return useQuery({
|
||||
queryKey: ['identities', deviceId],
|
||||
queryFn: () => (deviceId ? fetchIdentities(deviceId) : Promise.resolve([] as Identity[])),
|
||||
enabled: Boolean(deviceId),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIdentityPhotos(identityId: string | undefined, limit = 100): UseQueryResult<IdentityPhotoListItem[]> {
|
||||
return useQuery({
|
||||
queryKey: ['identity-photos', identityId, limit],
|
||||
queryFn: () => (identityId ? fetchIdentityPhotos(identityId, limit) : Promise.resolve([] as IdentityPhotoListItem[])),
|
||||
enabled: Boolean(identityId),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameIdentity(): UseMutationResult<void, Error, { id: string; name: string | null }> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, name }) => renameIdentity(id, name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['identities'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMergeIdentities(): UseMutationResult<void, Error, { targetId: string; sourceId: string }> {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ targetId, sourceId }) => mergeIdentities(targetId, sourceId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['identities'] });
|
||||
void qc.invalidateQueries({ queryKey: ['identity-photos'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import { Mail, Plus } from 'lucide-react';
|
||||
import { Mail, Plus, Search } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from '@lilith/ui-router';
|
||||
import { useMailConversations } from '@/hooks/useMail';
|
||||
import { useMessageSearch } from '@/hooks/useMessages';
|
||||
import { sendMail, type SendMailInput } from '@/api/mail';
|
||||
import { MailList } from './MailList';
|
||||
import { MailThread } from './MailThread';
|
||||
|
|
@ -37,6 +38,85 @@ const Toolbar = styled.div`
|
|||
padding: 8px 20px;
|
||||
border-bottom: 1px solid #2a2a38;
|
||||
background: #0a0a0f;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SearchWrap = styled.div`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px 6px 28px;
|
||||
background: #18182a;
|
||||
border: 1px solid #2a2a38;
|
||||
color: #e0e0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const SearchIconWrap = styled.span`
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6a6a7a;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const SearchResultsPanel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
const Hit = styled.button`
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1a1a26;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
&:hover { background: #14141e; }
|
||||
`;
|
||||
|
||||
const HitSubject = styled.div`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0f0;
|
||||
mark { background: #4a4af0; color: #fff; padding: 0 2px; border-radius: 2px; }
|
||||
`;
|
||||
|
||||
const HitMeta = styled.div`
|
||||
font-size: 11px;
|
||||
color: #8a8a9a;
|
||||
`;
|
||||
|
||||
const HitSnippet = styled.div`
|
||||
font-size: 12px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.3;
|
||||
mark { background: #4a4af0; color: #fff; padding: 0 2px; border-radius: 2px; }
|
||||
`;
|
||||
|
||||
const FuzzyBadge = styled.span`
|
||||
font-size: 10px;
|
||||
color: #b8b800;
|
||||
background: #2a2a18;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
const PrimaryButton = styled.button`
|
||||
|
|
@ -169,6 +249,43 @@ export function MailTab(): ReactElement {
|
|||
const [selectedId, setSelectedId] = useState<string | null>(convParam);
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [form, setForm] = useState<ComposeState>(blankForm);
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const search = useMessageSearch(debouncedQuery, { provider: 'icloud', limit: 30 });
|
||||
const searching = debouncedQuery.trim().length >= 2;
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(queryInput), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [queryInput]);
|
||||
|
||||
// Subject/snippet markers from server are «…»; convert to React <mark> nodes.
|
||||
// Returns an array of strings + <mark> elements. No HTML injection needed.
|
||||
const renderHighlight = useMemo(
|
||||
() => (raw: string | null | undefined): ReactElement[] => {
|
||||
const text = raw ?? '';
|
||||
const parts: ReactElement[] = [];
|
||||
let i = 0;
|
||||
let key = 0;
|
||||
while (i < text.length) {
|
||||
const start = text.indexOf('«', i);
|
||||
if (start === -1) {
|
||||
parts.push(<span key={key++}>{text.slice(i)}</span>);
|
||||
break;
|
||||
}
|
||||
if (start > i) parts.push(<span key={key++}>{text.slice(i, start)}</span>);
|
||||
const end = text.indexOf('»', start + 1);
|
||||
if (end === -1) {
|
||||
parts.push(<span key={key++}>{text.slice(start + 1)}</span>);
|
||||
break;
|
||||
}
|
||||
parts.push(<mark key={key++}>{text.slice(start + 1, end)}</mark>);
|
||||
i = end + 1;
|
||||
}
|
||||
return parts;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (convParam) setSelectedId(convParam);
|
||||
|
|
@ -212,6 +329,16 @@ export function MailTab(): ReactElement {
|
|||
>
|
||||
<Plus size={13} /> Compose
|
||||
</PrimaryButton>
|
||||
<SearchWrap>
|
||||
<SearchIconWrap><Search size={14} /></SearchIconWrap>
|
||||
<SearchInput
|
||||
type="search"
|
||||
placeholder="Search subject, sender, body…"
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
aria-label="Search mail"
|
||||
/>
|
||||
</SearchWrap>
|
||||
</Toolbar>
|
||||
|
||||
{composing && (
|
||||
|
|
@ -275,11 +402,46 @@ export function MailTab(): ReactElement {
|
|||
{!isLoading && !error && data?.length === 0 && (
|
||||
<EmptyState icon={<Mail size={28} />} heading="No emails" sub="Email threads will appear here once sync runs." />
|
||||
)}
|
||||
{!isLoading && !error && data && data.length > 0 && (
|
||||
<>
|
||||
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
|
||||
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</>
|
||||
{searching ? (
|
||||
<SearchResultsPanel>
|
||||
{search.isLoading && <SpinnerWrap aria-live="polite">Searching…</SpinnerWrap>}
|
||||
{search.error && <PageError message={String(search.error.message)} />}
|
||||
{!search.isLoading && !search.error && search.hits.length === 0 && (
|
||||
<ThreadCount>No matches for "{debouncedQuery}"</ThreadCount>
|
||||
)}
|
||||
{!search.isLoading && search.hits.length > 0 && (
|
||||
<>
|
||||
<ThreadCount>
|
||||
{search.total} match{search.total !== 1 ? 'es' : ''}
|
||||
</ThreadCount>
|
||||
{search.hits.map((hit) => (
|
||||
<Hit
|
||||
key={hit.message.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(hit.message.conversationId)}
|
||||
>
|
||||
<HitSubject>
|
||||
{renderHighlight(hit.subjectSnippet ?? hit.message.subject ?? '(no subject)')}
|
||||
</HitSubject>
|
||||
<HitMeta>
|
||||
{hit.message.fromHandle}
|
||||
{' · '}
|
||||
{new Date(hit.message.sentAt).toLocaleDateString()}
|
||||
{hit.fuzzy && <FuzzyBadge>did you mean…</FuzzyBadge>}
|
||||
</HitMeta>
|
||||
<HitSnippet>{renderHighlight(hit.snippet)}</HitSnippet>
|
||||
</Hit>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SearchResultsPanel>
|
||||
) : (
|
||||
!isLoading && !error && data && data.length > 0 && (
|
||||
<>
|
||||
<ThreadCount>{data.length} thread{data.length !== 1 ? 's' : ''}</ThreadCount>
|
||||
<MailList conversations={data} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</SidePanel>
|
||||
<Content>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
import { useMemo, useState, type ReactElement } from 'react';
|
||||
import { type ReactElement } from 'react';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import { Image, FolderOpen, Star, Users } from 'lucide-react';
|
||||
|
||||
import { Image } from 'lucide-react';
|
||||
import { usePhotos } from '@/hooks/usePhotos';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { PageError } from '@/components/PageError';
|
||||
import { usePhotos } from '@/hooks/usePhotos';
|
||||
import type { Album, Photo } from '@/types';
|
||||
|
||||
import { AlbumDetail } from './AlbumDetail';
|
||||
import { AlbumsView } from './AlbumsView';
|
||||
import { IdentitiesView } from './IdentitiesView';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import type { Photo } from '@/types';
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -19,42 +13,14 @@ const Root = styled.div`
|
|||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const PageHeading = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #2a2a38;
|
||||
background: #0a0a0f;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
const PageHeading = styled.h1`
|
||||
margin: 0;
|
||||
padding: 14px 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #e0e0f0;
|
||||
`;
|
||||
|
||||
const Tabs = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const TabButton = styled.button<{ $active: boolean }>`
|
||||
background: ${(p) => (p.$active ? '#1c1c26' : 'transparent')};
|
||||
color: ${(p) => (p.$active ? '#e0e0f0' : '#8a8a9a')};
|
||||
border: 1px solid ${(p) => (p.$active ? 'rgba(201,168,76,0.4)' : '#2a2a38')};
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #e0e0f0;
|
||||
}
|
||||
border-bottom: 1px solid #2a2a38;
|
||||
background: #0a0a0f;
|
||||
`;
|
||||
|
||||
const Meta = styled.span`
|
||||
|
|
@ -63,6 +29,77 @@ const Meta = styled.span`
|
|||
padding: 8px 20px 4px;
|
||||
`;
|
||||
|
||||
const Grid = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Tile = styled.li`
|
||||
aspect-ratio: 1;
|
||||
background: #1c1c26;
|
||||
border: 1px solid #2a2a38;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(201,168,76,0.4);
|
||||
}
|
||||
`;
|
||||
|
||||
const TileImage = styled.img`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
object-fit: cover;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
background: #13131a;
|
||||
`;
|
||||
|
||||
const TilePlaceholder = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3a3a4a;
|
||||
`;
|
||||
|
||||
const TileLabel = styled.span`
|
||||
font-size: 10px;
|
||||
color: #8a8a9a;
|
||||
padding: 4px 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const VideoBadge = styled.span`
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
const TileWrapper = styled.div`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const SpinnerWrap = styled.div`
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
|
|
@ -70,85 +107,52 @@ const SpinnerWrap = styled.div`
|
|||
text-align: center;
|
||||
`;
|
||||
|
||||
type View = 'all' | 'albums' | 'identities' | 'favorites';
|
||||
function buildPhotoUrl(photo: Photo): string | null {
|
||||
if (!photo.storageKey) return null;
|
||||
return `/my/photos/${encodeURIComponent(photo.storageKey)}`;
|
||||
}
|
||||
|
||||
function pickDeviceId(photos: readonly Photo[] | undefined): string | undefined {
|
||||
return photos?.[0]?.deviceId;
|
||||
function PhotoTile({ photo }: { photo: Photo }): ReactElement {
|
||||
const url = buildPhotoUrl(photo);
|
||||
const label = photo.filename || photo.externalId;
|
||||
|
||||
return (
|
||||
<Tile aria-label={label}>
|
||||
<TileWrapper>
|
||||
{url ? (
|
||||
<TileImage src={url} alt={label} loading="lazy" />
|
||||
) : (
|
||||
<TilePlaceholder aria-hidden="true">
|
||||
<Image size={24} />
|
||||
</TilePlaceholder>
|
||||
)}
|
||||
{photo.mediaType === 'video' && <VideoBadge aria-label="Video">video</VideoBadge>}
|
||||
{photo.mediaType === 'live_photo' && <VideoBadge aria-label="Live photo">live</VideoBadge>}
|
||||
</TileWrapper>
|
||||
<TileLabel title={label}>{label}</TileLabel>
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotosTab(): ReactElement {
|
||||
const [view, setView] = useState<View>('all');
|
||||
const [openAlbum, setOpenAlbum] = useState<Album | null>(null);
|
||||
|
||||
// Single anchor query to discover the deviceId. The other sub-views key off
|
||||
// the same deviceId — single-device deployment, so this is fine.
|
||||
const allPhotos = usePhotos({ limit: 500 });
|
||||
const deviceId = pickDeviceId(allPhotos.data);
|
||||
|
||||
const favorites = useMemo(
|
||||
() => (allPhotos.data ?? []).filter((p) => p.isFavorite),
|
||||
[allPhotos.data],
|
||||
);
|
||||
|
||||
const headerTabs: Array<{ id: View; icon: ReactElement; label: string }> = [
|
||||
{ id: 'all', icon: <Image size={14} />, label: 'All' },
|
||||
{ id: 'albums', icon: <FolderOpen size={14} />, label: 'Albums' },
|
||||
{ id: 'identities', icon: <Users size={14} />, label: 'People' },
|
||||
{ id: 'favorites', icon: <Star size={14} />, label: 'Favorites' },
|
||||
];
|
||||
const { data, isLoading, error } = usePhotos();
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<PageHeading>
|
||||
<Title>Photos</Title>
|
||||
<Tabs>
|
||||
{headerTabs.map((t) => (
|
||||
<TabButton
|
||||
key={t.id}
|
||||
$active={view === t.id && !openAlbum}
|
||||
onClick={() => {
|
||||
setView(t.id);
|
||||
setOpenAlbum(null);
|
||||
}}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</TabButton>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageHeading>
|
||||
|
||||
{openAlbum ? (
|
||||
<AlbumDetail album={openAlbum} onBack={() => setOpenAlbum(null)} />
|
||||
) : view === 'all' ? (
|
||||
<PageHeading>Photos</PageHeading>
|
||||
{isLoading && <SpinnerWrap aria-live="polite">Loading photos…</SpinnerWrap>}
|
||||
{error && <PageError message="Failed to load photos" />}
|
||||
{!isLoading && !error && data?.length === 0 && (
|
||||
<EmptyState icon={<Image size={36} />} heading="No photos" sub="Photos will appear here once sync runs." />
|
||||
)}
|
||||
{!isLoading && !error && data && data.length > 0 && (
|
||||
<>
|
||||
{allPhotos.isLoading && <SpinnerWrap aria-live="polite">Loading photos…</SpinnerWrap>}
|
||||
{allPhotos.error && <PageError message="Failed to load photos" />}
|
||||
{!allPhotos.isLoading && !allPhotos.error && (allPhotos.data?.length ?? 0) === 0 && (
|
||||
<EmptyState icon={<Image size={36} />} heading="No photos" sub="Photos will appear here once sync runs." />
|
||||
)}
|
||||
{!allPhotos.isLoading && !allPhotos.error && allPhotos.data && allPhotos.data.length > 0 && (
|
||||
<>
|
||||
<Meta>
|
||||
{allPhotos.data.length} photo{allPhotos.data.length !== 1 ? 's' : ''}
|
||||
</Meta>
|
||||
<PhotoGrid photos={allPhotos.data} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : view === 'albums' ? (
|
||||
<AlbumsView deviceId={deviceId} onAlbumClick={setOpenAlbum} />
|
||||
) : view === 'identities' ? (
|
||||
<IdentitiesView deviceId={deviceId} />
|
||||
) : (
|
||||
<>
|
||||
<Meta>
|
||||
{favorites.length} favorite{favorites.length !== 1 ? 's' : ''}
|
||||
</Meta>
|
||||
{favorites.length === 0 ? (
|
||||
<EmptyState icon={<Star size={36} />} heading="No favorites" sub="Stars in Photos.app show up here." />
|
||||
) : (
|
||||
<PhotoGrid photos={favorites} ariaLabel="Favorite photos" />
|
||||
)}
|
||||
<Meta>{data.length} photo{data.length !== 1 ? 's' : ''}</Meta>
|
||||
<Grid aria-label="Photo grid">
|
||||
{data.map((photo) => (
|
||||
<PhotoTile key={photo.id} photo={photo} />
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Root>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export interface Message {
|
|||
readonly deliveredAt: string | null;
|
||||
readonly readAt: string | null;
|
||||
readonly syncedAt: string;
|
||||
// Mail-only fields (null/empty for iMessage)
|
||||
readonly subject?: string | null;
|
||||
readonly bodyPlain?: string | null;
|
||||
readonly toHandles?: readonly string[];
|
||||
readonly ccHandles?: readonly string[];
|
||||
readonly folder?: string | null;
|
||||
}
|
||||
|
||||
export type SearchMode = 'lexical' | 'semantic' | 'hybrid';
|
||||
|
|
@ -36,7 +42,10 @@ export interface MessageSearchHit {
|
|||
readonly message: Message;
|
||||
readonly rank: number;
|
||||
readonly snippet: string;
|
||||
readonly subjectSnippet?: string | null;
|
||||
readonly mode: SearchMode;
|
||||
readonly kind: 'message' | 'mail';
|
||||
readonly fuzzy?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageSearchResponse {
|
||||
|
|
@ -116,8 +125,6 @@ export interface Contact {
|
|||
readonly summaryGeneratedAt: string | null;
|
||||
}
|
||||
|
||||
export type PhotoMediaKind = 'image' | 'video' | 'live_photo' | 'audio';
|
||||
|
||||
export interface Photo {
|
||||
readonly id: string;
|
||||
readonly deviceId: string;
|
||||
|
|
@ -125,33 +132,12 @@ export interface Photo {
|
|||
readonly externalId: string;
|
||||
readonly filename: string;
|
||||
readonly mediaType: string;
|
||||
readonly mediaKind: PhotoMediaKind | null;
|
||||
readonly mimeType: string | null;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly duration: number | null;
|
||||
readonly takenAt: string | null;
|
||||
readonly modifiedAt: string | null;
|
||||
readonly importedAt: string;
|
||||
readonly storageBucket: string | null;
|
||||
readonly storageKey: string | null;
|
||||
readonly thumbnailKey: string | null;
|
||||
readonly previewKey: string | null;
|
||||
readonly latitude: number | null;
|
||||
readonly longitude: number | null;
|
||||
readonly locationName: string | null;
|
||||
readonly isFavorite: boolean;
|
||||
readonly isHidden: boolean;
|
||||
readonly isScreenshot: boolean;
|
||||
readonly isSelfie: boolean;
|
||||
readonly isBurst: boolean;
|
||||
readonly burstIdentifier: string | null;
|
||||
readonly exif: Readonly<Record<string, unknown>> | null;
|
||||
readonly contentHash: string | null;
|
||||
readonly processingStatus: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped';
|
||||
readonly category: string | null;
|
||||
readonly semanticTags: readonly string[] | null;
|
||||
readonly faceCount: number;
|
||||
readonly syncedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -159,29 +145,8 @@ export interface Album {
|
|||
readonly id: string;
|
||||
readonly deviceId: string;
|
||||
readonly externalId: string;
|
||||
readonly name: string;
|
||||
readonly title: string;
|
||||
readonly albumType: 'user' | 'smart' | 'shared' | 'system';
|
||||
readonly photoCount: number;
|
||||
readonly startDate: string | null;
|
||||
readonly endDate: string | null;
|
||||
readonly coverPhotoId: string | null;
|
||||
readonly sortOrder: number;
|
||||
readonly syncedAt: string;
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
readonly id: string;
|
||||
readonly deviceId: string;
|
||||
readonly name: string | null;
|
||||
readonly faceCount: number;
|
||||
readonly centroidUpdatedAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IdentityPhotoListItem {
|
||||
readonly id: string;
|
||||
readonly thumbnailKey: string | null;
|
||||
readonly previewKey: string | null;
|
||||
readonly takenAt: string | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue