Compare commits
2
Commits
589c8cdf6e
...
17dc9719e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dc9719e9 | ||
|
|
0f58331654 |
@@ -1,6 +1,7 @@
|
|||||||
import type { CalendarEvent } from './shared';
|
import type { CalendarEvent } from './shared';
|
||||||
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
||||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
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
|
// 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
|
// 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 = () => {
|
export const CalendarView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
||||||
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
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 (isLoading) return <Placeholder text="Loading events…" />;
|
||||||
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no 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 { CalendarDays, Contact as ContactIcon } from 'lucide-react';
|
||||||
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
||||||
import { useDavCollections } from './useDavData';
|
import { useDavCollections } from './useDavData';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
// 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 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 CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { collections, isLoading } = useDavCollections(kind);
|
const { collections, isLoading } = useDavCollections(kind);
|
||||||
const fromUrl = params.get(COLLECTION_PARAM);
|
const fromUrl = params.get(COLLECTION_PARAM);
|
||||||
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!isLoading && collections.length === 0 && (
|
{!isLoading && collections.length === 0 && (
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
|
||||||
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CalendarNav = () => (
|
export const CalendarNav = () => {
|
||||||
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
|
const { t } = useTranslation();
|
||||||
);
|
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
|
||||||
|
};
|
||||||
|
|
||||||
export const ContactsNav = () => (
|
export const ContactsNav = () => {
|
||||||
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
|
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 { Building2, Mail, Phone, User } from 'lucide-react';
|
||||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||||
//
|
//
|
||||||
@@ -16,12 +17,13 @@ const initials = (name: string) =>
|
|||||||
.join('') || '?';
|
.join('') || '?';
|
||||||
|
|
||||||
export const ContactsView = () => {
|
export const ContactsView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
||||||
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
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 (isLoading) return <Placeholder text="Loading contacts…" />;
|
||||||
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no 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 jsQR from 'jsqr';
|
||||||
import { Camera, Download, RotateCcw } from 'lucide-react';
|
import { Camera, Download, RotateCcw } from 'lucide-react';
|
||||||
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Receiver: camera → jsQR → collect frames until every index is present.
|
// 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 };
|
type Progress = { meta: MetaFrame; have: number };
|
||||||
|
|
||||||
export const QrReceiver = () => {
|
export const QrReceiver = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const chunksRef = useRef(new Map<number, Uint8Array>());
|
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"
|
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" />
|
<Camera className="h-4 w-4" />
|
||||||
Start scanning
|
{t('qrTransfer.startScanning')}
|
||||||
</button>
|
</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"
|
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" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Receive another
|
{t('qrTransfer.receiveAnother')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scanning && (
|
{scanning && (
|
||||||
<button
|
<button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
|
||||||
type="button"
|
{t('qrTransfer.stop')}
|
||||||
onClick={stop}
|
|
||||||
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Upload, Play, Pause } from 'lucide-react';
|
import { Upload, Play, Pause } from 'lucide-react';
|
||||||
import {
|
import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
|
||||||
chunkFile,
|
import { useTranslation } from '../../locales';
|
||||||
crc32,
|
|
||||||
encodeData,
|
|
||||||
encodeMeta,
|
|
||||||
newSessionId,
|
|
||||||
DEFAULT_CHUNK_BYTES,
|
|
||||||
} from './protocol';
|
|
||||||
|
|
||||||
// Sender: a file becomes a looping carousel of QR frames.
|
// 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;
|
const SPEEDS = [4, 6, 8, 12] as const;
|
||||||
|
|
||||||
export const QrSender = () => {
|
export const QrSender = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
|
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
|
||||||
const [frames, setFrames] = useState<string[]>([]);
|
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">
|
<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">
|
<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" />
|
<Upload className="h-4 w-4" />
|
||||||
{file ? file.name : 'Choose a file to send'}
|
{file ? file.name : t('qrTransfer.chooseFile')}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -129,8 +124,8 @@ export const QrSender = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
|
<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
|
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
|
||||||
picked up on the next pass.
|
next pass.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,5 +11,17 @@
|
|||||||
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
|
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
|
||||||
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
|
"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}}).",
|
"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."
|
"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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user