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.
|
||||
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
|
||||
|
||||
# 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`.
|
||||
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.
|
||||
Le build et le lancement de l’image Docker n’ont pas été exécutés ici (accès au
|
||||
socket Docker indisponible dans le bac à sable).
|
||||
Cette sandbox ne pouvait pas lancer les conteneurs (socket Docker indisponible),
|
||||
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
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ docker compose up -d --build
|
||||
Requirements:
|
||||
|
||||
- `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 —
|
||||
it is what the ACME challenge uses to prove domain ownership.
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ http://{$SITE_ADDRESS} {
|
||||
|
||||
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'"
|
||||
# Apply HSTS to this site address only; deliberately omit includeSubDomains.
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import Stats from './routes/Stats.svelte';
|
||||
import DataSettings from './routes/DataSettings.svelte';
|
||||
import SyncSettings from './routes/SyncSettings.svelte';
|
||||
import About from './routes/About.svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { loadSyncStatus, synchronize, takeAutomationRole } from './lib/sync/client.js';
|
||||
import { syncState } from './lib/sync/state.svelte.js';
|
||||
@@ -120,6 +121,8 @@
|
||||
<DataSettings />
|
||||
{:else if view === 'sync'}
|
||||
<SyncSettings />
|
||||
{:else if view === 'about'}
|
||||
<About />
|
||||
{:else if ['settings', 'automations', 'non-working'].includes(view)}
|
||||
<Settings />
|
||||
{: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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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 {
|
||||
--bg: #ffffff;
|
||||
--fg: #000000;
|
||||
@@ -31,9 +52,10 @@
|
||||
--radius: 10px;
|
||||
--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;
|
||||
line-height: 1.35;
|
||||
color: var(--fg);
|
||||
@@ -44,12 +66,12 @@
|
||||
|
||||
:root[data-theme='nuit'] {
|
||||
--bg: #000000;
|
||||
--fg: #ffffff;
|
||||
--fg-dim: #cccccc;
|
||||
--fg: #e2bfbf;
|
||||
--fg-dim: #dba7a7;
|
||||
--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-soft: #3a2a00;
|
||||
|
||||
@@ -89,6 +111,7 @@ body {
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-heading);
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
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.
|
||||
*/
|
||||
|
||||
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() {
|
||||
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';
|
||||
</script>
|
||||
<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}
|
||||
<section class="card stack">
|
||||
<h2>Automatismes</h2>
|
||||
{#each AUTOMATION_OPTIONS as option (option.key)}
|
||||
<div class="automation-option stack">
|
||||
<label class="automation-toggle">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import ReopenDialog from '../components/ReopenDialog.svelte';
|
||||
import AboutLink from '../components/AboutLink.svelte';
|
||||
import { dayState } from '../lib/day.svelte.js';
|
||||
import { buildResume, resumeToText } from '../lib/resume.js';
|
||||
import { go, router } from '../lib/router.svelte.js';
|
||||
@@ -46,7 +47,7 @@
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
{#if dayState.closedDays.length > 1}
|
||||
<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; }}>
|
||||
{#each dayState.closedDays as day (day.date)}
|
||||
<option value={day.date}>{fmtDayLabel(day.date)} {day.date.slice(0, 4)}</option>
|
||||
@@ -62,9 +63,6 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="row-between">
|
||||
<h1>{resume.label}</h1>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
@@ -139,6 +137,8 @@
|
||||
{:else if !dayState.day && !dayState.todayRecord}
|
||||
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
|
||||
{/if}
|
||||
|
||||
<AboutLink />
|
||||
</div>
|
||||
|
||||
<ReopenDialog open={reopening} onclose={() => (reopening = false)} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
import AboutLink from '../components/AboutLink.svelte';
|
||||
import { dayState } from '../lib/day.svelte.js';
|
||||
import {
|
||||
ROUNDING_CHOICES,
|
||||
@@ -21,7 +22,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="screen" disabled={dayState.importing}>
|
||||
<div class="screen">
|
||||
<fieldset class="settings-content" disabled={dayState.importing}>
|
||||
<h1>Réglages</h1>
|
||||
|
||||
{#if settingsError.message}
|
||||
@@ -156,11 +158,18 @@
|
||||
<button type="button" onclick={() => go('stats')}>Consulter les statistiques</button>
|
||||
</section>
|
||||
|
||||
<AboutLink />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
fieldset.screen {
|
||||
fieldset.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
border: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.days {
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
{: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">
|
||||
<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>
|
||||
@@ -252,7 +252,7 @@
|
||||
<section class="card stack">
|
||||
<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>
|
||||
<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 () => {
|
||||
joining = await joinInvitation(code, name, pin);
|
||||
joiningQr = await makeJoiningQr(joining);
|
||||
@@ -331,7 +331,7 @@
|
||||
<code class="fingerprint">{comparison}</code>
|
||||
<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}
|
||||
<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>
|
||||
<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}
|
||||
@@ -373,7 +373,7 @@
|
||||
{:else}
|
||||
<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>
|
||||
<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>
|
||||
{/if}
|
||||
<button onclick={() => { action = ''; }}>Annuler</button>
|
||||
@@ -386,7 +386,45 @@
|
||||
label { display: flex; flex-direction: column; gap: var(--gap); }
|
||||
.check { flex-direction: row; align-items: start; }
|
||||
.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; }
|
||||
img { max-width: 100%; height: auto; align-self: center; }
|
||||
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
import { lunchGuidance } from '../lib/lunch.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 confirmingEnd = $state(false);
|
||||
@@ -49,6 +50,7 @@
|
||||
const current = $derived(running());
|
||||
const closedToday = $derived(dayState.todayRecord?.status === 'closed');
|
||||
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));
|
||||
|
||||
// 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'}
|
||||
</button>
|
||||
</div>
|
||||
{#if nonWorkingToday}<AboutLink />{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="screen">
|
||||
@@ -216,6 +219,7 @@
|
||||
<button disabled={busy || dayState.importing} onclick={() => (confirmingEnd = false)}>Annuler</button>
|
||||
<button class="btn-danger" disabled={busy || dayState.importing} onclick={() => act(finishDay)}>Confirmer</button>
|
||||
</Sheet>
|
||||
{#if nonWorkingToday}<AboutLink />{/if}
|
||||
</div>
|
||||
|
||||
<!-- End-of-day grace prompt --------------------------------------------- -->
|
||||
|
||||
Reference in New Issue
Block a user