More UI / UX changes
Added about page
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
# reachable from the internet — port 80 is what the ACME challenge uses.
|
# reachable from the internet — port 80 is what the ACME challenge uses.
|
||||||
SITE_ADDRESS=pardis.example.com
|
SITE_ADDRESS=pardis.example.com
|
||||||
|
|
||||||
# Notified by Let's Encrypt before a certificate expires. Optional but advised.
|
# Required contact address for Let's Encrypt certificate management.
|
||||||
ACME_EMAIL=you@example.com
|
ACME_EMAIL=you@example.com
|
||||||
|
|
||||||
# Adresse locale d’écoute ; laisser 0.0.0.0 pour toutes les interfaces.
|
# Adresse locale d’écoute ; laisser 0.0.0.0 pour toutes les interfaces.
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ Commandes : `npx playwright install chromium` puis `npm run validate:all`.
|
|||||||
Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`.
|
Un navigateur installé peut être choisi avec `PARDIS_CHROME_PATH`.
|
||||||
La CI installe Chromium avec ses dépendances et lance les mêmes parcours.
|
La CI installe Chromium avec ses dépendances et lance les mêmes parcours.
|
||||||
`docker compose config --quiet` a été validé avec des variables d’exemple.
|
`docker compose config --quiet` a été validé avec des variables d’exemple.
|
||||||
Le build et le lancement de l’image Docker n’ont pas été exécutés ici (accès au
|
Cette sandbox ne pouvait pas lancer les conteneurs (socket Docker indisponible),
|
||||||
socket Docker indisponible dans le bac à sable).
|
mais l'opérateur confirme que le déploiement Docker de production et son job
|
||||||
|
CI/CD fonctionnent et sont en service depuis plusieurs commits. La VM qui héberge
|
||||||
|
l'instance reçoit une sauvegarde quotidienne. Celle-ci protège l'hébergement et
|
||||||
|
le service de synchronisation, mais pas les données locales conservées dans les
|
||||||
|
navigateurs des utilisateurs.
|
||||||
|
|
||||||
## Vérifications sur appareils réels à terminer avant publication
|
## Vérifications sur appareils réels à terminer avant publication
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ docker compose up -d --build
|
|||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- `SITE_ADDRESS` must already resolve to the server.
|
- `SITE_ADDRESS` must already resolve to the server.
|
||||||
|
- `ACME_EMAIL` is required and must be a valid contact address.
|
||||||
- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional —
|
- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional —
|
||||||
it is what the ACME challenge uses to prove domain ownership.
|
it is what the ACME challenge uses to prove domain ownership.
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ http://{$SITE_ADDRESS} {
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||||
|
# Apply HSTS to this site address only; deliberately omit includeSubDomains.
|
||||||
|
Strict-Transport-Security "max-age=31536000"
|
||||||
X-Content-Type-Options nosniff
|
X-Content-Type-Options nosniff
|
||||||
X-Frame-Options DENY
|
X-Frame-Options DENY
|
||||||
Referrer-Policy strict-origin-when-cross-origin
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import Stats from './routes/Stats.svelte';
|
import Stats from './routes/Stats.svelte';
|
||||||
import DataSettings from './routes/DataSettings.svelte';
|
import DataSettings from './routes/DataSettings.svelte';
|
||||||
import SyncSettings from './routes/SyncSettings.svelte';
|
import SyncSettings from './routes/SyncSettings.svelte';
|
||||||
|
import About from './routes/About.svelte';
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
|
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
|
||||||
import { syncState } from './lib/sync/state.svelte.js';
|
import { syncState } from './lib/sync/state.svelte.js';
|
||||||
@@ -120,6 +121,8 @@
|
|||||||
<DataSettings />
|
<DataSettings />
|
||||||
{:else if view === 'sync'}
|
{:else if view === 'sync'}
|
||||||
<SyncSettings />
|
<SyncSettings />
|
||||||
|
{:else if view === 'about'}
|
||||||
|
<About />
|
||||||
{:else if ['settings', 'automations', 'non-working'].includes(view)}
|
{:else if ['settings', 'automations', 'non-working'].includes(view)}
|
||||||
<Settings />
|
<Settings />
|
||||||
{:else if view === 'today'}
|
{:else if view === 'today'}
|
||||||
|
|||||||
35
src/app.css
35
src/app.css
@@ -8,6 +8,27 @@
|
|||||||
* - Nothing tappable is under 64px; primary actions are 88px.
|
* - Nothing tappable is under 64px; primary actions are 88px.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When the font files are ready, add their @font-face declarations here and
|
||||||
|
* place them under public/fonts/ (for example, /fonts/pardis-text.woff2).
|
||||||
|
* Keeping these declarations commented avoids requests for files not yet shipped.
|
||||||
|
*
|
||||||
|
* @font-face {
|
||||||
|
* font-family: 'Pardis Text';
|
||||||
|
* src: url('/fonts/pardis-text.woff2') format('woff2');
|
||||||
|
* font-style: normal;
|
||||||
|
* font-weight: 400;
|
||||||
|
* font-display: swap;
|
||||||
|
* }
|
||||||
|
* @font-face {
|
||||||
|
* font-family: 'Pardis Headings';
|
||||||
|
* src: url('/fonts/pardis-headings.woff2') format('woff2');
|
||||||
|
* font-style: normal;
|
||||||
|
* font-weight: 400;
|
||||||
|
* font-display: swap;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--bg: #ffffff;
|
||||||
--fg: #000000;
|
--fg: #000000;
|
||||||
@@ -31,9 +52,10 @@
|
|||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--border: 3px;
|
--border: 3px;
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--font-body: 'Pardis Text', system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-heading: 'Pardis Headings', system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
|
||||||
font-family: var(--sans);
|
font-family: var(--font-body);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
@@ -44,12 +66,12 @@
|
|||||||
|
|
||||||
:root[data-theme='nuit'] {
|
:root[data-theme='nuit'] {
|
||||||
--bg: #000000;
|
--bg: #000000;
|
||||||
--fg: #ffffff;
|
--fg: #e2bfbf;
|
||||||
--fg-dim: #cccccc;
|
--fg-dim: #dba7a7;
|
||||||
--surface: #1a1a1a;
|
--surface: #1a1a1a;
|
||||||
--line: #ffffff;
|
--line: #774a4a;
|
||||||
|
|
||||||
--accent: #ffb000; /* amber reads well at night and preserves dark adaptation */
|
--accent: #e63100; /* amber reads well at night and preserves dark adaptation */
|
||||||
--accent-fg: #000000;
|
--accent-fg: #000000;
|
||||||
--accent-soft: #3a2a00;
|
--accent-soft: #3a2a00;
|
||||||
|
|
||||||
@@ -89,6 +111,7 @@ body {
|
|||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
|||||||
19
src/components/AboutLink.svelte
Normal file
19
src/components/AboutLink.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<footer class="screen-footer">
|
||||||
|
<a href="#/about">À propos de Pardis</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--pad);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--tap);
|
||||||
|
align-items: center;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* worker should land on.
|
* worker should land on.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working', 'data', 'sync'];
|
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working', 'data', 'sync', 'about'];
|
||||||
|
|
||||||
function fromHash() {
|
function fromHash() {
|
||||||
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
|
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
|
||||||
|
|||||||
83
src/routes/About.svelte
Normal file
83
src/routes/About.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script>
|
||||||
|
import { back } from '../lib/router.svelte.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="screen">
|
||||||
|
<button type="button" onclick={back}>Retour</button>
|
||||||
|
<h1>À propos de Pardis</h1>
|
||||||
|
<p>
|
||||||
|
Pardis est une application hors ligne de suivi des heures de chantier. Elle
|
||||||
|
enregistre les journées, les tâches, les clients, les repas et les pauses,
|
||||||
|
puis prépare un récapitulatif à consulter ou à copier.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Fonctionnement</h2>
|
||||||
|
<p>
|
||||||
|
Les données sont enregistrées dans le stockage du navigateur sur cet
|
||||||
|
appareil. L’application fonctionne hors ligne après son premier chargement.
|
||||||
|
Elle n’exécute pas les automatismes lorsque le navigateur ou la PWA est
|
||||||
|
fermé ou suspendu ; elle rattrape les étapes prévues à la prochaine
|
||||||
|
ouverture.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Les horaires automatiques sont des estimations. Vérifiez les journées
|
||||||
|
entièrement déduites avant de vous servir de leur récapitulatif.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Données et confidentialité</h2>
|
||||||
|
<p>
|
||||||
|
Pardis ne demande ni compte, ni adresse e-mail, ni mot de passe. Aucun
|
||||||
|
service d’analyse ou de suivi publicitaire n’est chargé. Les statistiques
|
||||||
|
sont calculées sur l’appareil.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
L’historique local reste dans le navigateur jusqu’à sa suppression par
|
||||||
|
l’utilisateur ou à l’effacement du stockage du site. Le réglage de
|
||||||
|
conservation détermine quelles journées peuvent être purgées ; la purge
|
||||||
|
est manuelle. Le stockage du navigateur n’est pas chiffré par Pardis et
|
||||||
|
reste accessible à cette origine sur un navigateur déverrouillé. Un export
|
||||||
|
peut être conservé ailleurs, mais il n’est pas chiffré.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
La synchronisation entre appareils est facultative. Lorsqu’elle est
|
||||||
|
activée, le contenu des journées et des réglages synchronisés est chiffré
|
||||||
|
de bout en bout. Le serveur ne peut pas lire cet historique, mais reçoit
|
||||||
|
des métadonnées nécessaires au service : adresse IP, identifiants et clés
|
||||||
|
publiques des appareils, liens entre révisions, tailles et dates de
|
||||||
|
connexion. Ces données sont conservées un an, conformément à la loi.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Supprimer une journée de l’historique synchronisé ne supprime pas ses
|
||||||
|
anciennes révisions chiffrées du coffre. Elles y restent jusqu’à la
|
||||||
|
suppression du coffre. Les sauvegardes de l’hébergement peuvent aussi
|
||||||
|
conserver des données supprimées jusqu’à l’expiration de leur rétention.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Opérateur de cette instance</h2>
|
||||||
|
<p>
|
||||||
|
Le service de synchronisation de cette instance est hébergé sur un serveur
|
||||||
|
sauvegardé quotidiennement. Les sauvegardes de ce serveur
|
||||||
|
protègent le service en ligne ; elles ne sauvegardent pas les données
|
||||||
|
conservées uniquement dans le navigateur de chaque utilisateur.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
L'application est développée par Adrien Weiskopff.
|
||||||
|
L'application et son serveur de production sont hébergés en France par
|
||||||
|
clawd.fr (EI Adrien Weiskopff).
|
||||||
|
Pour plus d'informations rendez-vous sur le site <a href="https://clawd.fr" target="_blank" title="vers le site de l'hébergeur">https://clawd.fr</a> ou écrivez
|
||||||
|
à l'adresse <a href="mailto:pardis@clawd.fr" title="contacter le développeur">pardis@clawd.fr</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen > p,
|
||||||
|
.card p {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
</script>
|
</script>
|
||||||
<fieldset class="screen" disabled={dayState.importing}>
|
<fieldset class="screen" disabled={dayState.importing}>
|
||||||
<p class="dim">Réglages</p><button onclick={() => go('settings')}>Retour aux réglages</button>
|
<h1>Automatismes</h1>
|
||||||
|
<button type="button" onclick={() => go('settings')}>Retour aux réglages</button>
|
||||||
{#if settingsError.message}<p role="alert">{settingsError.message}</p>{/if}
|
{#if settingsError.message}<p role="alert">{settingsError.message}</p>{/if}
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Automatismes</h2>
|
|
||||||
{#each AUTOMATION_OPTIONS as option (option.key)}
|
{#each AUTOMATION_OPTIONS as option (option.key)}
|
||||||
<div class="automation-option stack">
|
<div class="automation-option stack">
|
||||||
<label class="automation-toggle">
|
<label class="automation-toggle">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import ReopenDialog from '../components/ReopenDialog.svelte';
|
import ReopenDialog from '../components/ReopenDialog.svelte';
|
||||||
|
import AboutLink from '../components/AboutLink.svelte';
|
||||||
import { dayState } from '../lib/day.svelte.js';
|
import { dayState } from '../lib/day.svelte.js';
|
||||||
import { buildResume, resumeToText } from '../lib/resume.js';
|
import { buildResume, resumeToText } from '../lib/resume.js';
|
||||||
import { go, router } from '../lib/router.svelte.js';
|
import { go, router } from '../lib/router.svelte.js';
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
{#if error}<p role="alert">{error}</p>{/if}
|
{#if error}<p role="alert">{error}</p>{/if}
|
||||||
{#if dayState.closedDays.length > 1}
|
{#if dayState.closedDays.length > 1}
|
||||||
<label class="stack">
|
<label class="stack">
|
||||||
<span>Journée à consulter</span>
|
<h1>Récapitulatif</h1>
|
||||||
<select value={selectedDay?.date} onchange={(event) => { router.selectedDate = event.currentTarget.value; copied = false; }}>
|
<select value={selectedDay?.date} onchange={(event) => { router.selectedDate = event.currentTarget.value; copied = false; }}>
|
||||||
{#each dayState.closedDays as day (day.date)}
|
{#each dayState.closedDays as day (day.date)}
|
||||||
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
||||||
@@ -62,9 +63,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="row-between">
|
|
||||||
<h1>{resume.label}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedEntries.length && selectedEntries.every(e => e.automatic === true)}
|
{#if selectedEntries.length && selectedEntries.every(e => e.automatic === true)}
|
||||||
<p role="status" class="card">Journée entièrement déduite par les automatismes : vérifiez ses postes dans les détails avant de recopier.</p>
|
<p role="status" class="card">Journée entièrement déduite par les automatismes : vérifiez ses postes dans les détails avant de recopier.</p>
|
||||||
@@ -139,6 +137,8 @@
|
|||||||
{:else if !dayState.day && !dayState.todayRecord}
|
{:else if !dayState.day && !dayState.todayRecord}
|
||||||
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<AboutLink />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
|
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
|
import AboutLink from '../components/AboutLink.svelte';
|
||||||
import { dayState } from '../lib/day.svelte.js';
|
import { dayState } from '../lib/day.svelte.js';
|
||||||
import {
|
import {
|
||||||
ROUNDING_CHOICES,
|
ROUNDING_CHOICES,
|
||||||
@@ -21,7 +22,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="screen" disabled={dayState.importing}>
|
<div class="screen">
|
||||||
|
<fieldset class="settings-content" disabled={dayState.importing}>
|
||||||
<h1>Réglages</h1>
|
<h1>Réglages</h1>
|
||||||
|
|
||||||
{#if settingsError.message}
|
{#if settingsError.message}
|
||||||
@@ -156,11 +158,18 @@
|
|||||||
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
|
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AboutLink />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
fieldset.screen {
|
fieldset.settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
border: 0;
|
border: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.days {
|
.days {
|
||||||
|
|||||||
@@ -242,7 +242,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
|
<label>Nom de cet appareil<input type="text" bind:value={name} maxlength="80" autocomplete="off" /></label>
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Créer mon coffre</h2>
|
<h2>Créer mon coffre</h2>
|
||||||
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
|
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Lier à un appareil existant</h2>
|
<h2>Lier à un appareil existant</h2>
|
||||||
<p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p>
|
<p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p>
|
||||||
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
|
<label>Code d’association<input type="text" bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
|
||||||
<button disabled={!!joining} onclick={() => run(async () => {
|
<button disabled={!!joining} onclick={() => run(async () => {
|
||||||
joining = await joinInvitation(code, name, pin);
|
joining = await joinInvitation(code, name, pin);
|
||||||
joiningQr = await makeJoiningQr(joining);
|
joiningQr = await makeJoiningQr(joining);
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
<code class="fingerprint">{comparison}</code>
|
<code class="fingerprint">{comparison}</code>
|
||||||
<button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button>
|
<button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button>
|
||||||
{#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
|
{#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
|
||||||
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label>
|
<label>Nom du nouvel appareil<input type="text" bind:value={applicantName} maxlength="80" /></label>
|
||||||
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
|
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
|
||||||
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
|
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
|
||||||
{:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if}
|
{:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if}
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h2>Supprimer le coffre en ligne ?</h2>
|
<h2>Supprimer le coffre en ligne ?</h2>
|
||||||
<p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p>
|
<p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p>
|
||||||
<label>Tapez SUPPRIMER<input bind:value={deleteText} autocomplete="off" /></label>
|
<label>Tapez SUPPRIMER<input type="text" bind:value={deleteText} autocomplete="off" /></label>
|
||||||
<button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button>
|
<button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={() => { action = ''; }}>Annuler</button>
|
<button onclick={() => { action = ''; }}>Annuler</button>
|
||||||
@@ -386,7 +386,45 @@
|
|||||||
label { display: flex; flex-direction: column; gap: var(--gap); }
|
label { display: flex; flex-direction: column; gap: var(--gap); }
|
||||||
.check { flex-direction: row; align-items: start; }
|
.check { flex-direction: row; align-items: start; }
|
||||||
.check input { width: auto; min-height: auto; margin-top: .3rem; }
|
.check input { width: auto; min-height: auto; margin-top: .3rem; }
|
||||||
textarea { width: 100%; box-sizing: border-box; font: inherit; }
|
input[type='file'] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--tap);
|
||||||
|
padding: 8px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border) solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
input[type='file']::file-selector-button {
|
||||||
|
min-height: 44px;
|
||||||
|
margin-right: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border) solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--tap);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--border) solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
textarea:focus-visible,
|
||||||
|
input[type='file']:focus-visible {
|
||||||
|
outline: 4px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
|
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
|
||||||
img { max-width: 100%; height: auto; align-self: center; }
|
img { max-width: 100%; height: auto; align-self: center; }
|
||||||
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
|
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
import { lunchGuidance } from '../lib/lunch.js';
|
import { lunchGuidance } from '../lib/lunch.js';
|
||||||
import { settings } from '../lib/settings.svelte.js';
|
import { settings } from '../lib/settings.svelte.js';
|
||||||
import { fmtDuration, fmtTime } from '../lib/time.js';
|
import AboutLink from '../components/AboutLink.svelte';
|
||||||
|
import { fmtDuration, fmtTime, weekday } from '../lib/time.js';
|
||||||
|
|
||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
let confirmingEnd = $state(false);
|
let confirmingEnd = $state(false);
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
const current = $derived(running());
|
const current = $derived(running());
|
||||||
const closedToday = $derived(dayState.todayRecord?.status === 'closed');
|
const closedToday = $derived(dayState.todayRecord?.status === 'closed');
|
||||||
const blockedToday = $derived(settings.nonWorkingDates.includes(dayState.today));
|
const blockedToday = $derived(settings.nonWorkingDates.includes(dayState.today));
|
||||||
|
const nonWorkingToday = $derived(blockedToday || !settings.workdays.includes(weekday(dayState.today)));
|
||||||
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
|
const lunchHint = $derived(lunchGuidance(dayState.day, dayState.entries, settings, now));
|
||||||
|
|
||||||
// Snapshotted against writes, not against the clock: the proposed next task must
|
// Snapshotted against writes, not against the clock: the proposed next task must
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
{closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
|
{closedToday ? 'Reprendre la journée' : blockedToday ? 'Démarrer quand même' : 'Démarrer la journée'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if nonWorkingToday}<AboutLink />{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
@@ -216,6 +219,7 @@
|
|||||||
<button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
|
<button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
|
||||||
<button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
|
<button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
{#if nonWorkingToday}<AboutLink />{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- End-of-day grace prompt --------------------------------------------- -->
|
<!-- End-of-day grace prompt --------------------------------------------- -->
|
||||||
|
|||||||
Reference in New Issue
Block a user