Compare commits

...
2 Commits
Author SHA1 Message Date
pastilhas 17dc9719e9 calendar and contacts panels keyed (569 -> 564)
Everything except the appRegistryMetas names, which are blocked on a design decision:
AppRegistryEntry.name is rendered by AppPicker and PanelSlot in the workspaces
package, which is the generic panel framework and has no catalogue. Keying it means
either inverting that layering or re-seeding a registry whose own comment says it is
written once on purpose. Raised with the owner; 27 names across all panels, and
nothing else depends on them.
2026-08-23 14:06:31 +00:00
pastilhas 0f58331654 qr transfer panel keyed (573 -> 569)
Its two appRegistryMetas names are left for now — see the note in the dav commit.
2026-08-23 14:06:31 +00:00
7 changed files with 53 additions and 30 deletions
@@ -1,6 +1,7 @@
import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
@@ -26,12 +27,13 @@ const formatTime = (ev: CalendarEvent) => {
};
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
@@ -4,6 +4,7 @@ import { Link, useSearchParams } from 'react-router';
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData';
import { useTranslation } from '../../locales';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
//
@@ -22,6 +23,7 @@ type Props = {
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const { t } = useTranslation();
const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM);
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
);
})}
{!isLoading && collections.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground">
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
</p>
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
)}
</nav>
</div>
);
};
export const CalendarNav = () => (
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
);
export const CalendarNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
};
export const ContactsNav = () => (
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
);
export const ContactsNav = () => {
const { t } = useTranslation();
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
};
@@ -1,5 +1,6 @@
import { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /contacts: the selected address book as cards, sorted by name.
//
@@ -16,12 +17,13 @@ const initials = (name: string) =>
.join('') || '?';
export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr';
import { Camera, Download, RotateCcw } from 'lucide-react';
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
import { useTranslation } from '../../locales';
// Receiver: camera → jsQR → collect frames until every index is present.
//
@@ -15,6 +16,7 @@ import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
type Progress = { meta: MetaFrame; have: number };
export const QrReceiver = () => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chunksRef = useRef(new Map<number, Uint8Array>());
@@ -130,7 +132,7 @@ export const QrReceiver = () => {
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
>
<Camera className="h-4 w-4" />
Start scanning
{t('qrTransfer.startScanning')}
</button>
)}
@@ -172,18 +174,14 @@ export const QrReceiver = () => {
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
>
<RotateCcw className="h-3.5 w-3.5" />
Receive another
{t('qrTransfer.receiveAnother')}
</button>
</div>
)}
{scanning && (
<button
type="button"
onClick={stop}
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
>
Stop
<button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
{t('qrTransfer.stop')}
</button>
)}
</div>
@@ -1,14 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Upload, Play, Pause } from 'lucide-react';
import {
chunkFile,
crc32,
encodeData,
encodeMeta,
newSessionId,
DEFAULT_CHUNK_BYTES,
} from './protocol';
import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
import { useTranslation } from '../../locales';
// Sender: a file becomes a looping carousel of QR frames.
//
@@ -24,6 +18,7 @@ const META_EVERY = 12;
const SPEEDS = [4, 6, 8, 12] as const;
export const QrSender = () => {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
const [frames, setFrames] = useState<string[]>([]);
@@ -82,7 +77,7 @@ export const QrSender = () => {
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
<Upload className="h-4 w-4" />
{file ? file.name : 'Choose a file to send'}
{file ? file.name : t('qrTransfer.chooseFile')}
<input
type="file"
className="hidden"
@@ -129,8 +124,8 @@ export const QrSender = () => {
</div>
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is
picked up on the next pass.
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
next pass.
</p>
</>
)}
@@ -11,5 +11,17 @@
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
}
},
"qrTransfer": {
"chooseFile": "Choose a file to send",
"startScanning": "Start scanning",
"receiveAnother": "Receive another",
"stop": "Stop"
},
"dav": {
"empty": "Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.",
"calendar": "Calendar",
"contacts": "Contacts",
"readFailed": "Could not read {{name}}: {{reason}}"
}
}
@@ -11,5 +11,17 @@
"unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
"unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
}
},
"qrTransfer": {
"chooseFile": "Escolha um ficheiro para enviar",
"startScanning": "Começar a digitalizar",
"receiveAnother": "Receber outro",
"stop": "Parar"
},
"dav": {
"empty": "Ainda não há nada aqui. Adicione um a partir de um cliente de telemóvel ou computador por CalDAV/CardDAV.",
"calendar": "Calendário",
"contacts": "Contactos",
"readFailed": "Não foi possível ler {{name}}: {{reason}}"
}
}