feat(mobile): redesign connect screen and instances sheet
- Connect screen leads with Scan QR code plus a plain-words hint of where the code lives; manual URL entry is collapsed behind Connect by address (expanded automatically on web where scanning is unavailable); saved connections show a per-row connecting spinner - Instances sheet is list-first: the active instance shows a live status dot and transport (Connected - Local network / Private relay), rows connect on tap with an inline spinner, and the add/edit form hides behind Scan QR code / Add by address - Deleting the last instance returns to the connect screen: without a runtime endpoint the native app no longer bootstraps against the webview's own origin (which faked a successful connection), and the connect screen renders regardless of a stale isConnected flag
This commit is contained in:
+257
-184
@@ -61,6 +61,7 @@ import { MobileSessionsSheet } from './MobileSessionsSheet';
|
|||||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||||
import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
|
import { autoConnectLastInstance, connectionDisplayUrl, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
|
||||||
|
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||||
import { useAppFontEffects } from './useAppFontEffects';
|
import { useAppFontEffects } from './useAppFontEffects';
|
||||||
@@ -682,8 +683,12 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
|||||||
const [connectionName, setConnectionName] = React.useState('');
|
const [connectionName, setConnectionName] = React.useState('');
|
||||||
const [clientToken, setClientToken] = React.useState('');
|
const [clientToken, setClientToken] = React.useState('');
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const [isScanning, setIsScanning] = React.useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
|
||||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||||
|
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
||||||
|
// scanning is unavailable (web build) or the user asks for it.
|
||||||
|
const [manualOpen, setManualOpen] = React.useState(() => !isQrScanSupported());
|
||||||
|
// Which saved connection is being connected to, for the per-row spinner.
|
||||||
|
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
|
|
||||||
const handleSubmit = React.useCallback((event: React.FormEvent) => {
|
const handleSubmit = React.useCallback((event: React.FormEvent) => {
|
||||||
@@ -692,7 +697,7 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
|||||||
}, [clientToken, conn, connectionName, serverUrl]);
|
}, [clientToken, conn, connectionName, serverUrl]);
|
||||||
|
|
||||||
// Accept a pasted pairing link (openchamber://connect?...) in the URL field and
|
// Accept a pasted pairing link (openchamber://connect?...) in the URL field and
|
||||||
// split it back into the server URL + token, revealing the token field when present.
|
// split it back into the server URL + token.
|
||||||
const handleUrlChange = React.useCallback((value: string) => {
|
const handleUrlChange = React.useCallback((value: string) => {
|
||||||
if (/^openchamber:\/\//i.test(value.trim())) {
|
if (/^openchamber:\/\//i.test(value.trim())) {
|
||||||
const payload = parseConnectionPayload(value);
|
const payload = parseConnectionPayload(value);
|
||||||
@@ -704,7 +709,6 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
|||||||
setServerUrl(payload.url);
|
setServerUrl(payload.url);
|
||||||
if (payload.label) setConnectionName(payload.label);
|
if (payload.label) setConnectionName(payload.label);
|
||||||
if (payload.clientToken) setClientToken(payload.clientToken);
|
if (payload.clientToken) setClientToken(payload.clientToken);
|
||||||
if (payload.label || payload.clientToken) setAdvancedOpen(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -722,7 +726,6 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
|||||||
setServerUrl(result.url);
|
setServerUrl(result.url);
|
||||||
if (result.label) setConnectionName(result.label);
|
if (result.label) setConnectionName(result.label);
|
||||||
if (result.clientToken) setClientToken(result.clientToken);
|
if (result.clientToken) setClientToken(result.clientToken);
|
||||||
if (result.label || result.clientToken) setAdvancedOpen(true);
|
|
||||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||||
break;
|
break;
|
||||||
case 'pairing':
|
case 'pairing':
|
||||||
@@ -805,119 +808,133 @@ const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConn
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-6">
|
||||||
<form className="flex w-full flex-col gap-3" onSubmit={handleSubmit}>
|
{/* Primary path: scan the pairing QR from "Add a device" on the server. */}
|
||||||
<input
|
{qrScanSupported ? (
|
||||||
value={connectionName}
|
<div className="flex w-full flex-col gap-2">
|
||||||
onChange={(event) => setConnectionName(event.target.value)}
|
<Button
|
||||||
placeholder={t('mobile.instances.label.placeholder')}
|
type="button"
|
||||||
aria-label={t('mobile.instances.label.label')}
|
size="lg"
|
||||||
autoComplete="off"
|
className="h-12 w-full"
|
||||||
autoCapitalize="words"
|
onClick={() => void handleScanQr()}
|
||||||
autoCorrect="off"
|
disabled={isScanning || isBusy}
|
||||||
spellCheck={false}
|
>
|
||||||
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||||
/>
|
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.scanQr')}
|
||||||
<input
|
</Button>
|
||||||
{...mobileInputKeyboardProps}
|
<p className="px-2 text-center typography-small text-muted-foreground">
|
||||||
value={serverUrl}
|
{t('mobile.connect.welcome.scanHint')}
|
||||||
onChange={(event) => handleUrlChange(event.target.value)}
|
</p>
|
||||||
placeholder={t('mobile.connect.url.placeholder')}
|
</div>
|
||||||
aria-label={t('mobile.connect.url.label')}
|
) : null}
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
autoCapitalize="none"
|
|
||||||
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
{error && !manualOpen ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||||
|
|
||||||
|
{connections.length > 0 ? (
|
||||||
|
<section className="flex w-full flex-col gap-2.5">
|
||||||
|
<h2 className="text-center typography-micro uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{t('mobile.connect.saved.title')}
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||||
|
{connections.map((connection) => {
|
||||||
|
const isConnectingRow = connectingId === connection.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={connection.id}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-70"
|
||||||
|
onClick={() => {
|
||||||
|
setConnectingId(connection.id);
|
||||||
|
void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||||
|
.finally(() => setConnectingId(null));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||||
|
<Icon name="server" className="size-[18px]" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||||
|
<span className={cn('block truncate typography-small', isConnectingRow ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{isConnectingRow
|
||||||
|
? t('mobile.connect.connecting')
|
||||||
|
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isConnectingRow
|
||||||
|
? <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
: <Icon name="arrow-right-s" className="size-5 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Manual URL entry, collapsed by default — most people pair by QR. */}
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
{qrScanSupported ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAdvancedOpen((value) => !value)}
|
onClick={() => setManualOpen((value) => !value)}
|
||||||
aria-expanded={advancedOpen}
|
aria-expanded={manualOpen}
|
||||||
className="mx-auto flex items-center gap-1 rounded-full px-2 py-1 typography-small text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="mx-auto flex items-center gap-1 rounded-full px-2 py-1 typography-small text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
>
|
>
|
||||||
<span>{t('mobile.connect.advanced')}</span>
|
<span>{t('mobile.connect.manual.toggle')}</span>
|
||||||
<Icon name="arrow-down-s" className={cn('size-4 transition-transform duration-200', advancedOpen && 'rotate-180')} />
|
<Icon name="arrow-down-s" className={cn('size-4 transition-transform duration-200', manualOpen && 'rotate-180')} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
) : null}
|
||||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
<div
|
||||||
style={{ gridTemplateRows: advancedOpen ? '1fr' : '0fr' }}
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||||
>
|
style={{ gridTemplateRows: manualOpen ? '1fr' : '0fr' }}
|
||||||
<div className="min-h-0 overflow-hidden">
|
>
|
||||||
<div className="space-y-1.5 pt-2 text-left">
|
<div className="min-h-0 overflow-hidden">
|
||||||
<label className="block space-y-1.5">
|
<form className="flex w-full flex-col gap-3 pt-3" onSubmit={handleSubmit}>
|
||||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
<input
|
||||||
<input
|
{...mobileInputKeyboardProps}
|
||||||
{...mobileInputKeyboardProps}
|
value={serverUrl}
|
||||||
value={clientToken}
|
onChange={(event) => handleUrlChange(event.target.value)}
|
||||||
onChange={(event) => setClientToken(event.target.value)}
|
placeholder={t('mobile.connect.url.placeholder')}
|
||||||
placeholder={t('mobile.connect.token.placeholder')}
|
aria-label={t('mobile.connect.url.label')}
|
||||||
tabIndex={advancedOpen ? undefined : -1}
|
type="url"
|
||||||
autoCapitalize="none"
|
inputMode="url"
|
||||||
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
autoCapitalize="none"
|
||||||
/>
|
tabIndex={manualOpen ? undefined : -1}
|
||||||
</label>
|
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||||
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
/>
|
||||||
</div>
|
<input
|
||||||
</div>
|
value={connectionName}
|
||||||
|
onChange={(event) => setConnectionName(event.target.value)}
|
||||||
|
placeholder={t('mobile.instances.label.placeholder')}
|
||||||
|
aria-label={t('mobile.instances.label.label')}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
tabIndex={manualOpen ? undefined : -1}
|
||||||
|
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
{...mobileInputKeyboardProps}
|
||||||
|
value={clientToken}
|
||||||
|
onChange={(event) => setClientToken(event.target.value)}
|
||||||
|
placeholder={t('mobile.connect.token.placeholder')}
|
||||||
|
aria-label={t('mobile.connect.token.label')}
|
||||||
|
tabIndex={manualOpen ? undefined : -1}
|
||||||
|
autoCapitalize="none"
|
||||||
|
className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
<p className="px-1 text-center typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||||
|
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||||
|
<Button type="submit" variant={qrScanSupported ? 'outline' : 'default'} size="lg" className="h-12 w-full" disabled={isBusy || isScanning || !serverUrl.trim()}>
|
||||||
|
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.connectButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
||||||
|
|
||||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isBusy || isScanning || !serverUrl.trim()}>
|
|
||||||
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.connectButton')}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="lg"
|
|
||||||
className="h-12 w-full"
|
|
||||||
onClick={() => void handleScanQr()}
|
|
||||||
disabled={!qrScanSupported || isScanning || isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
|
||||||
{t('mobile.connect.scanQr')}
|
|
||||||
</Button>
|
|
||||||
{!qrScanSupported ? (
|
|
||||||
<p className="px-1 text-center typography-micro text-muted-foreground">
|
|
||||||
{t('mobile.connect.scan.unsupported')}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!pendingConnection && connections.length > 0 ? (
|
|
||||||
<section className="flex w-full flex-col gap-2.5">
|
|
||||||
<h2 className="text-center typography-micro uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
{t('mobile.connect.saved.title')}
|
|
||||||
</h2>
|
|
||||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
|
||||||
{connections.map((connection) => (
|
|
||||||
<button
|
|
||||||
key={connection.id}
|
|
||||||
type="button"
|
|
||||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
|
||||||
onClick={() => void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })}
|
|
||||||
>
|
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
|
||||||
<Icon name="server" className="size-[18px]" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
|
||||||
<span className="block truncate typography-small text-muted-foreground">
|
|
||||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<Icon name="arrow-right-s" className="size-5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -942,6 +959,11 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const [isScanning, setIsScanning] = React.useState(false);
|
||||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||||
|
// The manual add/edit form is hidden until asked for — the sheet leads with
|
||||||
|
// the list of instances (with live status), not a wall of inputs.
|
||||||
|
const [formOpen, setFormOpen] = React.useState(false);
|
||||||
|
// Which row is being connected to, for the per-row spinner.
|
||||||
|
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
|
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
|
||||||
// an effect keyed on the derived connection object. With an effect, any churn of the
|
// an effect keyed on the derived connection object. With an effect, any churn of the
|
||||||
@@ -953,6 +975,7 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
setLabel('');
|
setLabel('');
|
||||||
setClientToken('');
|
setClientToken('');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFormOpen(false);
|
||||||
}, [setError]);
|
}, [setError]);
|
||||||
|
|
||||||
const saveInstance = React.useCallback((event: React.FormEvent) => {
|
const saveInstance = React.useCallback((event: React.FormEvent) => {
|
||||||
@@ -972,9 +995,11 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
const result = await scanConnectionQr();
|
const result = await scanConnectionQr();
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
|
// Legacy token QR: prefill the manual form for review before saving.
|
||||||
setUrl(result.url);
|
setUrl(result.url);
|
||||||
if (result.label) setLabel(result.label);
|
if (result.label) setLabel(result.label);
|
||||||
if (result.clientToken) setClientToken(result.clientToken);
|
if (result.clientToken) setClientToken(result.clientToken);
|
||||||
|
setFormOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'pairing':
|
case 'pairing':
|
||||||
await conn.redeemPairingConnection(result.pairing);
|
await conn.redeemPairingConnection(result.pairing);
|
||||||
@@ -1019,13 +1044,16 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
const confirmDelete = React.useCallback((id: string) => {
|
const confirmDelete = React.useCallback((id: string) => {
|
||||||
setConfirmingDeleteId(null);
|
setConfirmingDeleteId(null);
|
||||||
if (editingId === id) resetForm();
|
if (editingId === id) resetForm();
|
||||||
|
// Removing the ACTIVE instance — or the LAST one — must drop the user back
|
||||||
|
// to the connect screen instead of leaving them in a stale, unbacked UI.
|
||||||
|
const wasLast = connections.length === 1;
|
||||||
void removeConnection(id).then((removed) => {
|
void removeConnection(id).then((removed) => {
|
||||||
if (!removed) return;
|
if (!removed) return;
|
||||||
if (isActiveRuntimeConnection(removed)) {
|
if (wasLast || isActiveRuntimeConnection(removed)) {
|
||||||
onActiveConnectionDeleted();
|
onActiveConnectionDeleted();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]);
|
}, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]);
|
||||||
|
|
||||||
const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20';
|
const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20';
|
||||||
|
|
||||||
@@ -1071,11 +1099,20 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
<div className="space-y-7">
|
<div className="space-y-6">
|
||||||
{connections.length > 0 ? (
|
{connections.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||||
{connections.map((connection) => {
|
{connections.map((connection) => {
|
||||||
const confirming = confirmingDeleteId === connection.id;
|
const confirming = confirmingDeleteId === connection.id;
|
||||||
|
const isActive = isActiveRuntimeConnection(connection);
|
||||||
|
const isConnectingRow = connectingId === connection.id;
|
||||||
|
// Status line: the active instance says HOW it is connected right
|
||||||
|
// now (direct vs relay); others show their address.
|
||||||
|
const statusText = isConnectingRow
|
||||||
|
? t('mobile.connect.connecting')
|
||||||
|
: isActive
|
||||||
|
? (isRelayModeActive() ? t('mobile.instances.status.connectedRelay') : t('mobile.instances.status.connectedDirect'))
|
||||||
|
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={connection.id}
|
key={connection.id}
|
||||||
@@ -1087,18 +1124,30 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
||||||
onClick={() => void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })}
|
onClick={() => {
|
||||||
disabled={isBusy || confirming}
|
if (isActive) return;
|
||||||
|
setConnectingId(connection.id);
|
||||||
|
void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||||
|
.finally(() => setConnectingId(null));
|
||||||
|
}}
|
||||||
|
disabled={(isBusy && !isConnectingRow) || confirming}
|
||||||
>
|
>
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
<span className="relative flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||||
<Icon name="server" className="size-[18px]" />
|
<Icon name="server" className="size-[18px]" />
|
||||||
|
{isActive ? (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-[var(--surface-elevated)] bg-[var(--status-success)]" aria-hidden />
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||||
<span className="block truncate typography-small text-muted-foreground">
|
<span className={cn(
|
||||||
{connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
'block truncate typography-small',
|
||||||
|
isActive && !isConnectingRow ? 'text-[var(--status-success)]' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{isConnectingRow ? <Icon name="loader-4" className="size-5 shrink-0 animate-spin text-muted-foreground" /> : null}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-0.5 pr-2">
|
<div className="flex items-center gap-0.5 pr-2">
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
@@ -1151,76 +1200,88 @@ const MobileInstancesSurface: React.FC<{
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={saveInstance}>
|
{/* Add actions: QR pairing is the primary path; the manual form stays
|
||||||
<div className="flex h-8 items-center justify-between gap-3 px-1">
|
hidden until asked for (or until a row's edit button opens it). */}
|
||||||
<h3 className="typography-ui-label text-foreground">
|
{!formOpen && !editingConnection ? (
|
||||||
{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}
|
<div className="space-y-2">
|
||||||
</h3>
|
{qrScanSupported ? (
|
||||||
{editingConnection ? (
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 w-full"
|
||||||
|
onClick={() => void handleScanInstance()}
|
||||||
|
disabled={isScanning}
|
||||||
|
>
|
||||||
|
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||||
|
{t('mobile.connect.scanQr')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={qrScanSupported ? 'ghost' : 'outline'}
|
||||||
|
size="lg"
|
||||||
|
className="h-12 w-full"
|
||||||
|
onClick={() => { setError(null); setFormOpen(true); }}
|
||||||
|
>
|
||||||
|
<Icon name="add" className="size-[18px]" />
|
||||||
|
{t('mobile.instances.addManual')}
|
||||||
|
</Button>
|
||||||
|
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-3" onSubmit={saveInstance}>
|
||||||
|
<div className="flex h-8 items-center justify-between gap-3 px-1">
|
||||||
|
<h3 className="typography-ui-label text-foreground">
|
||||||
|
{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}
|
||||||
|
</h3>
|
||||||
<Button type="button" variant="ghost" size="xs" onClick={resetForm}>
|
<Button type="button" variant="ghost" size="xs" onClick={resetForm}>
|
||||||
{t('mobile.instances.cancelEdit')}
|
{t('mobile.instances.cancelEdit')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
<label className="block space-y-1.5">
|
||||||
<div>
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.url.label')}</span>
|
||||||
<Button
|
<input
|
||||||
type="button"
|
{...mobileInputKeyboardProps}
|
||||||
variant="outline"
|
value={url}
|
||||||
size="lg"
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
className="h-12 w-full"
|
placeholder={t('mobile.connect.url.placeholder')}
|
||||||
onClick={() => void handleScanInstance()}
|
type="url"
|
||||||
disabled={!qrScanSupported || isScanning}
|
inputMode="url"
|
||||||
>
|
autoCapitalize="none"
|
||||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
className={inputClass}
|
||||||
{t('mobile.connect.scanQr')}
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.instances.label.label')}</span>
|
||||||
|
<input
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
placeholder={t('mobile.instances.label.placeholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="words"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
||||||
|
<input
|
||||||
|
{...mobileInputKeyboardProps}
|
||||||
|
value={clientToken}
|
||||||
|
onChange={(event) => setClientToken(event.target.value)}
|
||||||
|
placeholder={t('mobile.connect.token.placeholder')}
|
||||||
|
autoCapitalize="none"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||||
|
</label>
|
||||||
|
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||||
|
<Button type="submit" size="lg" className="mt-1 h-12 w-full">
|
||||||
|
{editingConnection ? t('mobile.instances.saveEdit') : t('mobile.instances.saveNew')}
|
||||||
</Button>
|
</Button>
|
||||||
{!qrScanSupported ? (
|
</form>
|
||||||
<p className="px-1 pt-1.5 typography-micro text-muted-foreground">{t('mobile.connect.scan.unsupported')}</p>
|
)}
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<label className="block space-y-1.5">
|
|
||||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.instances.label.label')}</span>
|
|
||||||
<input
|
|
||||||
value={label}
|
|
||||||
onChange={(event) => setLabel(event.target.value)}
|
|
||||||
placeholder={t('mobile.instances.label.placeholder')}
|
|
||||||
autoComplete="off"
|
|
||||||
autoCapitalize="words"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck={false}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block space-y-1.5">
|
|
||||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.url.label')}</span>
|
|
||||||
<input
|
|
||||||
{...mobileInputKeyboardProps}
|
|
||||||
value={url}
|
|
||||||
onChange={(event) => setUrl(event.target.value)}
|
|
||||||
placeholder={t('mobile.connect.url.placeholder')}
|
|
||||||
type="url"
|
|
||||||
inputMode="url"
|
|
||||||
autoCapitalize="none"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block space-y-1.5">
|
|
||||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
|
||||||
<input
|
|
||||||
{...mobileInputKeyboardProps}
|
|
||||||
value={clientToken}
|
|
||||||
onChange={(event) => setClientToken(event.target.value)}
|
|
||||||
placeholder={t('mobile.connect.token.placeholder')}
|
|
||||||
autoCapitalize="none"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
|
||||||
</label>
|
|
||||||
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
||||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full">
|
|
||||||
{editingConnection ? t('mobile.instances.saveEdit') : t('mobile.instances.saveNew')}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2793,8 +2854,14 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
}, [setIsMobile]);
|
}, [setIsMobile]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// Never bootstrap without a runtime endpoint on native: with apiBaseUrl ''
|
||||||
|
// the resolver falls back to the webview's own origin, where Capacitor's
|
||||||
|
// static server answers every request with index.html — the bootstrap
|
||||||
|
// "succeeds" against a fake backend and flips isConnected back on, leaving
|
||||||
|
// the user in an empty shell after a disconnect.
|
||||||
|
if (isNativeMobileApp && !getRuntimeApiBaseUrl()) return;
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
}, [connectionEpoch, initializeApp]);
|
}, [connectionEpoch, initializeApp, isNativeMobileApp]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
@@ -2937,10 +3004,16 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isConnected && !isReconnecting && isNativeMobileApp) {
|
// No runtime endpoint on native = explicitly disconnected (last instance
|
||||||
|
// deleted, revoked token, unreachable). The connect screen is the only valid
|
||||||
|
// UI then — regardless of what a stale isConnected flag claims (the store can
|
||||||
|
// be poisoned by a bootstrap that ran against the webview's own origin).
|
||||||
|
const hasRuntimeEndpoint = Boolean(getRuntimeApiBaseUrl());
|
||||||
|
|
||||||
|
if (isNativeMobileApp && (!hasRuntimeEndpoint || (!isConnected && !isReconnecting))) {
|
||||||
// A runtime endpoint is already selected (first connect or switching instances):
|
// A runtime endpoint is already selected (first connect or switching instances):
|
||||||
// show a loader while it re-bootstraps instead of flashing the onboarding screen.
|
// show a loader while it re-bootstraps instead of flashing the onboarding screen.
|
||||||
if (getRuntimeApiBaseUrl()) {
|
if (hasRuntimeEndpoint) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh items-center justify-center bg-background px-6 text-center text-foreground">
|
<main className="flex min-h-dvh items-center justify-center bg-background px-6 text-center text-foreground">
|
||||||
<div className="flex max-w-sm flex-col items-center gap-4">
|
<div className="flex max-w-sm flex-col items-center gap-4">
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export const dict = {
|
|||||||
'mobile.connect.cancelPassword': 'Use another server',
|
'mobile.connect.cancelPassword': 'Use another server',
|
||||||
'mobile.connect.connecting': 'Connecting...',
|
'mobile.connect.connecting': 'Connecting...',
|
||||||
'mobile.connect.scanQr': 'Scan QR code',
|
'mobile.connect.scanQr': 'Scan QR code',
|
||||||
|
'mobile.connect.welcome.scanHint': 'On your computer, open «Add a device» to show a QR code, then scan it here.',
|
||||||
'mobile.connect.advanced': 'Advanced',
|
'mobile.connect.advanced': 'Advanced',
|
||||||
|
'mobile.connect.manual.toggle': 'Connect by address',
|
||||||
'mobile.connect.scan.permissionDenied': 'Camera access is off. Enable it in Settings to scan a QR code.',
|
'mobile.connect.scan.permissionDenied': 'Camera access is off. Enable it in Settings to scan a QR code.',
|
||||||
'mobile.connect.scan.failed': 'Could not scan that QR code. Try again or enter the URL manually.',
|
'mobile.connect.scan.failed': 'Could not scan that QR code. Try again or enter the URL manually.',
|
||||||
'mobile.connect.scan.invalid': 'That QR code is not an OpenChamber connection code.',
|
'mobile.connect.scan.invalid': 'That QR code is not an OpenChamber connection code.',
|
||||||
@@ -65,6 +67,7 @@ export const dict = {
|
|||||||
'mobile.connect.error.authRequired': 'This server needs a password or client token.',
|
'mobile.connect.error.authRequired': 'This server needs a password or client token.',
|
||||||
'mobile.connect.error.passwordFailed': 'Could not unlock that server. Check the password.',
|
'mobile.connect.error.passwordFailed': 'Could not unlock that server. Check the password.',
|
||||||
'mobile.instances.addTitle': 'Add instance',
|
'mobile.instances.addTitle': 'Add instance',
|
||||||
|
'mobile.instances.addManual': 'Add by address',
|
||||||
'mobile.instances.editTitle': 'Edit instance',
|
'mobile.instances.editTitle': 'Edit instance',
|
||||||
'mobile.instances.edit': 'Edit',
|
'mobile.instances.edit': 'Edit',
|
||||||
'mobile.instances.delete': 'Delete',
|
'mobile.instances.delete': 'Delete',
|
||||||
@@ -75,6 +78,8 @@ export const dict = {
|
|||||||
'mobile.instances.label.label': 'Name',
|
'mobile.instances.label.label': 'Name',
|
||||||
'mobile.instances.label.placeholder': 'Optional display name',
|
'mobile.instances.label.placeholder': 'Optional display name',
|
||||||
'mobile.instances.saveNew': 'Save instance',
|
'mobile.instances.saveNew': 'Save instance',
|
||||||
|
'mobile.instances.status.connectedDirect': 'Connected · Local network',
|
||||||
|
'mobile.instances.status.connectedRelay': 'Connected · Private relay',
|
||||||
'mobile.instances.saveEdit': 'Save changes',
|
'mobile.instances.saveEdit': 'Save changes',
|
||||||
'mobile.nav.changes': 'Changes',
|
'mobile.nav.changes': 'Changes',
|
||||||
'mobile.nav.settings': 'Settings',
|
'mobile.nav.settings': 'Settings',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.cancelPassword": "Usar otro servidor",
|
"mobile.connect.cancelPassword": "Usar otro servidor",
|
||||||
"mobile.connect.connecting": "Conectando...",
|
"mobile.connect.connecting": "Conectando...",
|
||||||
"mobile.connect.scanQr": "Escanear código QR",
|
"mobile.connect.scanQr": "Escanear código QR",
|
||||||
|
"mobile.connect.welcome.scanHint": "En tu ordenador, abre «Añadir un dispositivo» para mostrar un código QR y escanéalo aquí.",
|
||||||
"mobile.connect.advanced": "Avanzado",
|
"mobile.connect.advanced": "Avanzado",
|
||||||
|
"mobile.connect.manual.toggle": "Conectar por dirección",
|
||||||
"mobile.connect.scan.permissionDenied": "El acceso a la cámara está desactivado. Actívalo en Ajustes para escanear un código QR.",
|
"mobile.connect.scan.permissionDenied": "El acceso a la cámara está desactivado. Actívalo en Ajustes para escanear un código QR.",
|
||||||
"mobile.connect.scan.failed": "No se pudo escanear ese código QR. Inténtalo de nuevo o introduce la URL manualmente.",
|
"mobile.connect.scan.failed": "No se pudo escanear ese código QR. Inténtalo de nuevo o introduce la URL manualmente.",
|
||||||
"mobile.connect.scan.invalid": "Ese código QR no es un código de conexión de OpenChamber.",
|
"mobile.connect.scan.invalid": "Ese código QR no es un código de conexión de OpenChamber.",
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.",
|
"mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.",
|
||||||
"mobile.connect.error.passwordFailed": "No se pudo desbloquear ese servidor. Revisa la contraseña.",
|
"mobile.connect.error.passwordFailed": "No se pudo desbloquear ese servidor. Revisa la contraseña.",
|
||||||
"mobile.instances.addTitle": "Agregar instancia",
|
"mobile.instances.addTitle": "Agregar instancia",
|
||||||
|
"mobile.instances.addManual": "Añadir por dirección",
|
||||||
"mobile.instances.editTitle": "Editar instancia",
|
"mobile.instances.editTitle": "Editar instancia",
|
||||||
"mobile.instances.edit": "Editar",
|
"mobile.instances.edit": "Editar",
|
||||||
"mobile.instances.delete": "Eliminar",
|
"mobile.instances.delete": "Eliminar",
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.instances.label.label": "Nombre",
|
"mobile.instances.label.label": "Nombre",
|
||||||
"mobile.instances.label.placeholder": "Nombre para mostrar (opcional)",
|
"mobile.instances.label.placeholder": "Nombre para mostrar (opcional)",
|
||||||
"mobile.instances.saveNew": "Guardar instancia",
|
"mobile.instances.saveNew": "Guardar instancia",
|
||||||
|
"mobile.instances.status.connectedDirect": "Conectado · Red local",
|
||||||
|
"mobile.instances.status.connectedRelay": "Conectado · Relay privado",
|
||||||
"mobile.instances.saveEdit": "Guardar cambios",
|
"mobile.instances.saveEdit": "Guardar cambios",
|
||||||
"mobile.nav.changes": "Cambios",
|
"mobile.nav.changes": "Cambios",
|
||||||
"mobile.nav.settings": "Ajustes",
|
"mobile.nav.settings": "Ajustes",
|
||||||
|
|||||||
@@ -2509,7 +2509,9 @@ export const dict = {
|
|||||||
'mobile.connect.cancelPassword': 'Utiliser un autre serveur',
|
'mobile.connect.cancelPassword': 'Utiliser un autre serveur',
|
||||||
'mobile.connect.connecting': 'Connexion...',
|
'mobile.connect.connecting': 'Connexion...',
|
||||||
'mobile.connect.scanQr': 'Scanner le code QR',
|
'mobile.connect.scanQr': 'Scanner le code QR',
|
||||||
|
'mobile.connect.welcome.scanHint': 'Sur votre ordinateur, ouvrez « Ajouter un appareil » pour afficher un code QR, puis scannez-le ici.',
|
||||||
'mobile.connect.advanced': 'Avancé',
|
'mobile.connect.advanced': 'Avancé',
|
||||||
|
'mobile.connect.manual.toggle': 'Connexion par adresse',
|
||||||
'mobile.connect.scan.permissionDenied': 'L\'accès à la caméra est désactivé. Activez-le dans les Réglages pour scanner un code QR.',
|
'mobile.connect.scan.permissionDenied': 'L\'accès à la caméra est désactivé. Activez-le dans les Réglages pour scanner un code QR.',
|
||||||
'mobile.connect.scan.failed': 'Impossible de scanner ce code QR. Réessayez ou saisissez l\'URL manuellement.',
|
'mobile.connect.scan.failed': 'Impossible de scanner ce code QR. Réessayez ou saisissez l\'URL manuellement.',
|
||||||
'mobile.connect.scan.invalid': 'Ce code QR n\'est pas un code de connexion OpenChamber.',
|
'mobile.connect.scan.invalid': 'Ce code QR n\'est pas un code de connexion OpenChamber.',
|
||||||
@@ -2523,6 +2525,7 @@ export const dict = {
|
|||||||
'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.',
|
'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.',
|
||||||
'mobile.connect.error.passwordFailed': 'Impossible de déverrouiller ce serveur. Vérifiez le mot de passe.',
|
'mobile.connect.error.passwordFailed': 'Impossible de déverrouiller ce serveur. Vérifiez le mot de passe.',
|
||||||
'mobile.instances.addTitle': 'Ajouter une instance',
|
'mobile.instances.addTitle': 'Ajouter une instance',
|
||||||
|
'mobile.instances.addManual': 'Ajouter par adresse',
|
||||||
'mobile.instances.editTitle': 'Modifier l\'instance',
|
'mobile.instances.editTitle': 'Modifier l\'instance',
|
||||||
'mobile.instances.edit': 'Modifier',
|
'mobile.instances.edit': 'Modifier',
|
||||||
'mobile.instances.delete': 'Supprimer',
|
'mobile.instances.delete': 'Supprimer',
|
||||||
@@ -2533,6 +2536,8 @@ export const dict = {
|
|||||||
'mobile.instances.label.label': 'Nom',
|
'mobile.instances.label.label': 'Nom',
|
||||||
'mobile.instances.label.placeholder': 'Nom d\'affichage facultatif',
|
'mobile.instances.label.placeholder': 'Nom d\'affichage facultatif',
|
||||||
'mobile.instances.saveNew': 'Enregistrer l\'instance',
|
'mobile.instances.saveNew': 'Enregistrer l\'instance',
|
||||||
|
'mobile.instances.status.connectedDirect': 'Connecté · Réseau local',
|
||||||
|
'mobile.instances.status.connectedRelay': 'Connecté · Relais privé',
|
||||||
'mobile.instances.saveEdit': 'Enregistrer les modifications',
|
'mobile.instances.saveEdit': 'Enregistrer les modifications',
|
||||||
'mobile.nav.changes': 'Modifications',
|
'mobile.nav.changes': 'Modifications',
|
||||||
'mobile.nav.settings': 'Paramètres',
|
'mobile.nav.settings': 'Paramètres',
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.url.label': 'サーバー URL',
|
'mobile.connect.url.label': 'サーバー URL',
|
||||||
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
|
'mobile.connect.url.placeholder': 'http://192.168.1.74:2606',
|
||||||
'mobile.connect.scanQr': 'QR コードをスキャン',
|
'mobile.connect.scanQr': 'QR コードをスキャン',
|
||||||
|
'mobile.connect.welcome.scanHint': 'コンピュータで「デバイスを追加」を開いてQRコードを表示し、ここでスキャンしてください。',
|
||||||
'mobile.connect.advanced': '詳細設定',
|
'mobile.connect.advanced': '詳細設定',
|
||||||
|
'mobile.connect.manual.toggle': 'アドレスで接続',
|
||||||
'mobile.connect.token.label': 'クライアントトークン',
|
'mobile.connect.token.label': 'クライアントトークン',
|
||||||
'mobile.connect.token.placeholder': 'アクセストークンを貼り付け',
|
'mobile.connect.token.placeholder': 'アクセストークンを貼り付け',
|
||||||
'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。',
|
'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。',
|
||||||
@@ -69,10 +71,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.scan.invalid': 'その QR コードは OpenChamber の接続コードではありません。',
|
'mobile.connect.scan.invalid': 'その QR コードは OpenChamber の接続コードではありません。',
|
||||||
'mobile.connect.scan.failed': 'その QR コードを読み取れませんでした。もう一度試すか、URL を手動で入力してください。',
|
'mobile.connect.scan.failed': 'その QR コードを読み取れませんでした。もう一度試すか、URL を手動で入力してください。',
|
||||||
'mobile.instances.addTitle': 'インスタンスを追加',
|
'mobile.instances.addTitle': 'インスタンスを追加',
|
||||||
|
'mobile.instances.addManual': 'アドレスで追加',
|
||||||
'mobile.instances.editTitle': 'インスタンスを編集',
|
'mobile.instances.editTitle': 'インスタンスを編集',
|
||||||
'mobile.instances.label.label': '名前',
|
'mobile.instances.label.label': '名前',
|
||||||
'mobile.instances.label.placeholder': '表示名(任意)',
|
'mobile.instances.label.placeholder': '表示名(任意)',
|
||||||
'mobile.instances.saveNew': 'インスタンスを保存',
|
'mobile.instances.saveNew': 'インスタンスを保存',
|
||||||
|
'mobile.instances.status.connectedDirect': '接続中 · ローカルネットワーク',
|
||||||
|
'mobile.instances.status.connectedRelay': '接続中 · プライベートリレー',
|
||||||
'mobile.instances.saveEdit': '変更を保存',
|
'mobile.instances.saveEdit': '変更を保存',
|
||||||
'mobile.instances.cancelEdit': 'キャンセル',
|
'mobile.instances.cancelEdit': 'キャンセル',
|
||||||
'mobile.instances.edit': '編集',
|
'mobile.instances.edit': '編集',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.cancelPassword': '다른 서버 사용',
|
'mobile.connect.cancelPassword': '다른 서버 사용',
|
||||||
'mobile.connect.connecting': '연결 중...',
|
'mobile.connect.connecting': '연결 중...',
|
||||||
'mobile.connect.scanQr': 'QR 코드 스캔',
|
'mobile.connect.scanQr': 'QR 코드 스캔',
|
||||||
|
'mobile.connect.welcome.scanHint': '컴퓨터에서 「기기 추가」를 열어 QR 코드를 표시한 뒤 여기에서 스캔하세요.',
|
||||||
'mobile.connect.advanced': '고급',
|
'mobile.connect.advanced': '고급',
|
||||||
|
'mobile.connect.manual.toggle': '주소로 연결',
|
||||||
'mobile.connect.scan.permissionDenied': '카메라 접근이 꺼져 있습니다. QR 코드를 스캔하려면 설정에서 사용 설정하세요.',
|
'mobile.connect.scan.permissionDenied': '카메라 접근이 꺼져 있습니다. QR 코드를 스캔하려면 설정에서 사용 설정하세요.',
|
||||||
'mobile.connect.scan.failed': 'QR 코드를 스캔하지 못했습니다. 다시 시도하거나 URL을 직접 입력하세요.',
|
'mobile.connect.scan.failed': 'QR 코드를 스캔하지 못했습니다. 다시 시도하거나 URL을 직접 입력하세요.',
|
||||||
'mobile.connect.scan.invalid': '이 QR 코드는 OpenChamber 연결 코드가 아닙니다.',
|
'mobile.connect.scan.invalid': '이 QR 코드는 OpenChamber 연결 코드가 아닙니다.',
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
|
'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.',
|
||||||
'mobile.connect.error.passwordFailed': '서버 잠금을 해제할 수 없습니다. 비밀번호를 확인하세요.',
|
'mobile.connect.error.passwordFailed': '서버 잠금을 해제할 수 없습니다. 비밀번호를 확인하세요.',
|
||||||
'mobile.instances.addTitle': '인스턴스 추가',
|
'mobile.instances.addTitle': '인스턴스 추가',
|
||||||
|
'mobile.instances.addManual': '주소로 추가',
|
||||||
'mobile.instances.editTitle': '인스턴스 편집',
|
'mobile.instances.editTitle': '인스턴스 편집',
|
||||||
'mobile.instances.edit': '편집',
|
'mobile.instances.edit': '편집',
|
||||||
'mobile.instances.delete': '삭제',
|
'mobile.instances.delete': '삭제',
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.instances.label.label': '이름',
|
'mobile.instances.label.label': '이름',
|
||||||
'mobile.instances.label.placeholder': '표시 이름 (선택 사항)',
|
'mobile.instances.label.placeholder': '표시 이름 (선택 사항)',
|
||||||
'mobile.instances.saveNew': '인스턴스 저장',
|
'mobile.instances.saveNew': '인스턴스 저장',
|
||||||
|
'mobile.instances.status.connectedDirect': '연결됨 · 로컬 네트워크',
|
||||||
|
'mobile.instances.status.connectedRelay': '연결됨 · 비공개 릴레이',
|
||||||
'mobile.instances.saveEdit': '변경 사항 저장',
|
'mobile.instances.saveEdit': '변경 사항 저장',
|
||||||
'mobile.nav.changes': '변경사항',
|
'mobile.nav.changes': '변경사항',
|
||||||
'mobile.nav.settings': '설정',
|
'mobile.nav.settings': '설정',
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.cancelPassword': 'Użyj innego serwera',
|
'mobile.connect.cancelPassword': 'Użyj innego serwera',
|
||||||
'mobile.connect.connecting': 'Łączenie...',
|
'mobile.connect.connecting': 'Łączenie...',
|
||||||
'mobile.connect.scanQr': 'Skanuj kod QR',
|
'mobile.connect.scanQr': 'Skanuj kod QR',
|
||||||
|
'mobile.connect.welcome.scanHint': 'Na komputerze otwórz «Dodaj urządzenie», aby wyświetlić kod QR, i zeskanuj go tutaj.',
|
||||||
'mobile.connect.advanced': 'Zaawansowane',
|
'mobile.connect.advanced': 'Zaawansowane',
|
||||||
|
'mobile.connect.manual.toggle': 'Połącz przez adres',
|
||||||
'mobile.connect.scan.permissionDenied': 'Dostęp do aparatu jest wyłączony. Włącz go w Ustawieniach, aby zeskanować kod QR.',
|
'mobile.connect.scan.permissionDenied': 'Dostęp do aparatu jest wyłączony. Włącz go w Ustawieniach, aby zeskanować kod QR.',
|
||||||
'mobile.connect.scan.failed': 'Nie udało się zeskanować tego kodu QR. Spróbuj ponownie lub wpisz adres URL ręcznie.',
|
'mobile.connect.scan.failed': 'Nie udało się zeskanować tego kodu QR. Spróbuj ponownie lub wpisz adres URL ręcznie.',
|
||||||
'mobile.connect.scan.invalid': 'Ten kod QR nie jest kodem połączenia OpenChamber.',
|
'mobile.connect.scan.invalid': 'Ten kod QR nie jest kodem połączenia OpenChamber.',
|
||||||
@@ -67,6 +69,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.',
|
'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.',
|
||||||
'mobile.connect.error.passwordFailed': 'Nie udało się odblokować tego serwera. Sprawdź hasło.',
|
'mobile.connect.error.passwordFailed': 'Nie udało się odblokować tego serwera. Sprawdź hasło.',
|
||||||
'mobile.instances.addTitle': 'Dodaj instancję',
|
'mobile.instances.addTitle': 'Dodaj instancję',
|
||||||
|
'mobile.instances.addManual': 'Dodaj przez adres',
|
||||||
'mobile.instances.editTitle': 'Edytuj instancję',
|
'mobile.instances.editTitle': 'Edytuj instancję',
|
||||||
'mobile.instances.edit': 'Edytuj',
|
'mobile.instances.edit': 'Edytuj',
|
||||||
'mobile.instances.delete': 'Usuń',
|
'mobile.instances.delete': 'Usuń',
|
||||||
@@ -77,6 +80,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.instances.label.label': 'Nazwa',
|
'mobile.instances.label.label': 'Nazwa',
|
||||||
'mobile.instances.label.placeholder': 'Opcjonalna nazwa wyświetlana',
|
'mobile.instances.label.placeholder': 'Opcjonalna nazwa wyświetlana',
|
||||||
'mobile.instances.saveNew': 'Zapisz instancję',
|
'mobile.instances.saveNew': 'Zapisz instancję',
|
||||||
|
'mobile.instances.status.connectedDirect': 'Połączono · Sieć lokalna',
|
||||||
|
'mobile.instances.status.connectedRelay': 'Połączono · Prywatny relay',
|
||||||
'mobile.instances.saveEdit': 'Zapisz zmiany',
|
'mobile.instances.saveEdit': 'Zapisz zmiany',
|
||||||
'mobile.nav.changes': 'Zmiany',
|
'mobile.nav.changes': 'Zmiany',
|
||||||
'mobile.nav.settings': 'Ustawienia',
|
'mobile.nav.settings': 'Ustawienia',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.cancelPassword": "Usar outro servidor",
|
"mobile.connect.cancelPassword": "Usar outro servidor",
|
||||||
"mobile.connect.connecting": "Conectando...",
|
"mobile.connect.connecting": "Conectando...",
|
||||||
"mobile.connect.scanQr": "Ler código QR",
|
"mobile.connect.scanQr": "Ler código QR",
|
||||||
|
"mobile.connect.welcome.scanHint": "No seu computador, abra «Adicionar um dispositivo» para mostrar um código QR e escaneie aqui.",
|
||||||
"mobile.connect.advanced": "Avançado",
|
"mobile.connect.advanced": "Avançado",
|
||||||
|
"mobile.connect.manual.toggle": "Conectar por endereço",
|
||||||
"mobile.connect.scan.permissionDenied": "O acesso à câmera está desativado. Ative-o nos Ajustes para ler um código QR.",
|
"mobile.connect.scan.permissionDenied": "O acesso à câmera está desativado. Ative-o nos Ajustes para ler um código QR.",
|
||||||
"mobile.connect.scan.failed": "Não foi possível ler esse código QR. Tente novamente ou digite a URL manualmente.",
|
"mobile.connect.scan.failed": "Não foi possível ler esse código QR. Tente novamente ou digite a URL manualmente.",
|
||||||
"mobile.connect.scan.invalid": "Esse código QR não é um código de conexão do OpenChamber.",
|
"mobile.connect.scan.invalid": "Esse código QR não é um código de conexão do OpenChamber.",
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.",
|
"mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.",
|
||||||
"mobile.connect.error.passwordFailed": "Não foi possível desbloquear esse servidor. Verifique a senha.",
|
"mobile.connect.error.passwordFailed": "Não foi possível desbloquear esse servidor. Verifique a senha.",
|
||||||
"mobile.instances.addTitle": "Adicionar instância",
|
"mobile.instances.addTitle": "Adicionar instância",
|
||||||
|
"mobile.instances.addManual": "Adicionar por endereço",
|
||||||
"mobile.instances.editTitle": "Editar instância",
|
"mobile.instances.editTitle": "Editar instância",
|
||||||
"mobile.instances.edit": "Editar",
|
"mobile.instances.edit": "Editar",
|
||||||
"mobile.instances.delete": "Excluir",
|
"mobile.instances.delete": "Excluir",
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.instances.label.label": "Nome",
|
"mobile.instances.label.label": "Nome",
|
||||||
"mobile.instances.label.placeholder": "Nome de exibição opcional",
|
"mobile.instances.label.placeholder": "Nome de exibição opcional",
|
||||||
"mobile.instances.saveNew": "Salvar instância",
|
"mobile.instances.saveNew": "Salvar instância",
|
||||||
|
"mobile.instances.status.connectedDirect": "Conectado · Rede local",
|
||||||
|
"mobile.instances.status.connectedRelay": "Conectado · Relay privado",
|
||||||
"mobile.instances.saveEdit": "Salvar alterações",
|
"mobile.instances.saveEdit": "Salvar alterações",
|
||||||
"mobile.nav.changes": "Alterações",
|
"mobile.nav.changes": "Alterações",
|
||||||
"mobile.nav.settings": "Configurações",
|
"mobile.nav.settings": "Configurações",
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.cancelPassword": "Інший сервер",
|
"mobile.connect.cancelPassword": "Інший сервер",
|
||||||
"mobile.connect.connecting": "Підключення...",
|
"mobile.connect.connecting": "Підключення...",
|
||||||
"mobile.connect.scanQr": "Сканувати QR-код",
|
"mobile.connect.scanQr": "Сканувати QR-код",
|
||||||
|
"mobile.connect.welcome.scanHint": "На компʼютері відкрийте «Додати пристрій», щоб показати QR-код, і відскануйте його тут.",
|
||||||
"mobile.connect.advanced": "Додатково",
|
"mobile.connect.advanced": "Додатково",
|
||||||
|
"mobile.connect.manual.toggle": "Підключитися за адресою",
|
||||||
"mobile.connect.scan.permissionDenied": "Доступ до камери вимкнено. Увімкни його в Налаштуваннях, щоб сканувати QR-код.",
|
"mobile.connect.scan.permissionDenied": "Доступ до камери вимкнено. Увімкни його в Налаштуваннях, щоб сканувати QR-код.",
|
||||||
"mobile.connect.scan.failed": "Не вдалося відсканувати QR-код. Спробуй ще раз або введи адресу вручну.",
|
"mobile.connect.scan.failed": "Не вдалося відсканувати QR-код. Спробуй ще раз або введи адресу вручну.",
|
||||||
"mobile.connect.scan.invalid": "Це не QR-код підключення OpenChamber.",
|
"mobile.connect.scan.invalid": "Це не QR-код підключення OpenChamber.",
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.",
|
"mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.",
|
||||||
"mobile.connect.error.passwordFailed": "Не вдалося розблокувати сервер. Перевір пароль.",
|
"mobile.connect.error.passwordFailed": "Не вдалося розблокувати сервер. Перевір пароль.",
|
||||||
"mobile.instances.addTitle": "Додати інстанс",
|
"mobile.instances.addTitle": "Додати інстанс",
|
||||||
|
"mobile.instances.addManual": "Додати за адресою",
|
||||||
"mobile.instances.editTitle": "Редагувати інстанс",
|
"mobile.instances.editTitle": "Редагувати інстанс",
|
||||||
"mobile.instances.edit": "Редагувати",
|
"mobile.instances.edit": "Редагувати",
|
||||||
"mobile.instances.delete": "Видалити",
|
"mobile.instances.delete": "Видалити",
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"mobile.instances.label.label": "Назва",
|
"mobile.instances.label.label": "Назва",
|
||||||
"mobile.instances.label.placeholder": "Необовʼязкова назва",
|
"mobile.instances.label.placeholder": "Необовʼязкова назва",
|
||||||
"mobile.instances.saveNew": "Зберегти інстанс",
|
"mobile.instances.saveNew": "Зберегти інстанс",
|
||||||
|
"mobile.instances.status.connectedDirect": "Підключено · Локальна мережа",
|
||||||
|
"mobile.instances.status.connectedRelay": "Підключено · Приватний relay",
|
||||||
"mobile.instances.saveEdit": "Зберегти зміни",
|
"mobile.instances.saveEdit": "Зберегти зміни",
|
||||||
"mobile.nav.changes": "Зміни",
|
"mobile.nav.changes": "Зміни",
|
||||||
"mobile.nav.settings": "Налаштування",
|
"mobile.nav.settings": "Налаштування",
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.cancelPassword': '使用其他服务器',
|
'mobile.connect.cancelPassword': '使用其他服务器',
|
||||||
'mobile.connect.connecting': '连接中...',
|
'mobile.connect.connecting': '连接中...',
|
||||||
'mobile.connect.scanQr': '扫描二维码',
|
'mobile.connect.scanQr': '扫描二维码',
|
||||||
|
'mobile.connect.welcome.scanHint': '在电脑上打开「添加设备」显示二维码,然后在这里扫描。',
|
||||||
'mobile.connect.advanced': '高级',
|
'mobile.connect.advanced': '高级',
|
||||||
|
'mobile.connect.manual.toggle': '通过地址连接',
|
||||||
'mobile.connect.scan.permissionDenied': '相机访问已关闭。请在“设置”中开启以扫描二维码。',
|
'mobile.connect.scan.permissionDenied': '相机访问已关闭。请在“设置”中开启以扫描二维码。',
|
||||||
'mobile.connect.scan.failed': '无法扫描该二维码。请重试或手动输入网址。',
|
'mobile.connect.scan.failed': '无法扫描该二维码。请重试或手动输入网址。',
|
||||||
'mobile.connect.scan.invalid': '该二维码不是 OpenChamber 连接码。',
|
'mobile.connect.scan.invalid': '该二维码不是 OpenChamber 连接码。',
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。',
|
'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。',
|
||||||
'mobile.connect.error.passwordFailed': '无法解锁该服务器。请检查密码。',
|
'mobile.connect.error.passwordFailed': '无法解锁该服务器。请检查密码。',
|
||||||
'mobile.instances.addTitle': '添加实例',
|
'mobile.instances.addTitle': '添加实例',
|
||||||
|
'mobile.instances.addManual': '通过地址添加',
|
||||||
'mobile.instances.editTitle': '编辑实例',
|
'mobile.instances.editTitle': '编辑实例',
|
||||||
'mobile.instances.edit': '编辑',
|
'mobile.instances.edit': '编辑',
|
||||||
'mobile.instances.delete': '删除',
|
'mobile.instances.delete': '删除',
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.instances.label.label': '名称',
|
'mobile.instances.label.label': '名称',
|
||||||
'mobile.instances.label.placeholder': '可选显示名称',
|
'mobile.instances.label.placeholder': '可选显示名称',
|
||||||
'mobile.instances.saveNew': '保存实例',
|
'mobile.instances.saveNew': '保存实例',
|
||||||
|
'mobile.instances.status.connectedDirect': '已连接 · 局域网',
|
||||||
|
'mobile.instances.status.connectedRelay': '已连接 · 私有中继',
|
||||||
'mobile.instances.saveEdit': '保存更改',
|
'mobile.instances.saveEdit': '保存更改',
|
||||||
'mobile.nav.changes': '更改',
|
'mobile.nav.changes': '更改',
|
||||||
'mobile.nav.settings': '设置',
|
'mobile.nav.settings': '设置',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.cancelPassword': '使用其他伺服器',
|
'mobile.connect.cancelPassword': '使用其他伺服器',
|
||||||
'mobile.connect.connecting': '連線中...',
|
'mobile.connect.connecting': '連線中...',
|
||||||
'mobile.connect.scanQr': '掃描 QR code',
|
'mobile.connect.scanQr': '掃描 QR code',
|
||||||
|
'mobile.connect.welcome.scanHint': '在電腦上開啟「新增裝置」顯示 QR 代碼,然後在這裡掃描。',
|
||||||
'mobile.connect.advanced': '進階',
|
'mobile.connect.advanced': '進階',
|
||||||
|
'mobile.connect.manual.toggle': '透過位址連線',
|
||||||
'mobile.connect.scan.permissionDenied': '相機存取已關閉。請在「設定」中開啟以掃描 QR code。',
|
'mobile.connect.scan.permissionDenied': '相機存取已關閉。請在「設定」中開啟以掃描 QR code。',
|
||||||
'mobile.connect.scan.failed': '無法掃描該 QR code。請重試或手動輸入網址。',
|
'mobile.connect.scan.failed': '無法掃描該 QR code。請重試或手動輸入網址。',
|
||||||
'mobile.connect.scan.invalid': '此 QR code 不是 OpenChamber 連線代碼。',
|
'mobile.connect.scan.invalid': '此 QR code 不是 OpenChamber 連線代碼。',
|
||||||
@@ -66,6 +68,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。',
|
'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。',
|
||||||
'mobile.connect.error.passwordFailed': '無法解鎖該伺服器。請檢查密碼。',
|
'mobile.connect.error.passwordFailed': '無法解鎖該伺服器。請檢查密碼。',
|
||||||
'mobile.instances.addTitle': '新增執行個體',
|
'mobile.instances.addTitle': '新增執行個體',
|
||||||
|
'mobile.instances.addManual': '透過位址新增',
|
||||||
'mobile.instances.editTitle': '編輯執行個體',
|
'mobile.instances.editTitle': '編輯執行個體',
|
||||||
'mobile.instances.edit': '編輯',
|
'mobile.instances.edit': '編輯',
|
||||||
'mobile.instances.delete': '刪除',
|
'mobile.instances.delete': '刪除',
|
||||||
@@ -76,6 +79,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'mobile.instances.label.label': '名稱',
|
'mobile.instances.label.label': '名稱',
|
||||||
'mobile.instances.label.placeholder': '選填顯示名稱',
|
'mobile.instances.label.placeholder': '選填顯示名稱',
|
||||||
'mobile.instances.saveNew': '儲存執行個體',
|
'mobile.instances.saveNew': '儲存執行個體',
|
||||||
|
'mobile.instances.status.connectedDirect': '已連線 · 區域網路',
|
||||||
|
'mobile.instances.status.connectedRelay': '已連線 · 私人中繼',
|
||||||
'mobile.instances.saveEdit': '儲存變更',
|
'mobile.instances.saveEdit': '儲存變更',
|
||||||
'mobile.nav.changes': '變更',
|
'mobile.nav.changes': '變更',
|
||||||
'mobile.nav.settings': '設定',
|
'mobile.nav.settings': '設定',
|
||||||
|
|||||||
Reference in New Issue
Block a user