Files
flowdeck/app/templates/import.html
T
bruno de751ffe35
FlowDeck CI / test (push) Successful in 15m24s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / docker (push) Successful in 1m52s
feat: A20 TERMINÉ — Alpine en build CSP, unsafe-eval retiré de la CSP (v7.43.0)
La bascule A20 phase 3 :
- static/js/alpine.csp.min.js (build officiel @alpinejs/csp, 0
  eval/new Function, parseur maison) servi partout : base.html,
  import.html, welcome.html + entree sw.js (cache bump v8).
- CSP : script-src 'self' 'nonce-…' — unsafe-eval SUPPRIMÉ (ne servait
  plus qu'Alpine standard). htmx allowEval:false deja pose (v7.37).
- Assertion test inversée : assert "'unsafe-eval'" not in script_src.
- Scan statique final sur TOUS les templates : 0 expression incompatible
  (4 residus = faux positifs dans des chaines de texte).

Pré-requis réunis par les lots 1-3 : 12 surfaces migrées + gateées
(csp_preview), registres Alpine.data, x-html → x-init+Alpine.effect,
délégués window.E, partage d'état lexical, bug topbar corrigé.

Verifs : suite **1094/1094** · ruff OK · eslint 0/0 · **E2E 7/7 sous
CSP reel** (script-src sans unsafe-eval verifie sur l'instance).

Hors gate (scan propre, gitea down) : board/table_view/teamload/
card_detail → à vérifier au premier usage avec gitea remonté (noté
ROADMAP/CHANGELOG).
2026-10-02 16:00:12 -04:00

216 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:32px 20px;}
.wrap{width:860px;max-width:96vw;margin:0 auto;}
.brand{font-size:15px;font-weight:700;margin-bottom:22px;display:flex;align-items:center;gap:8px;color:var(--dim);}
.brand a{color:var(--dim);text-decoration:none;}
.brand a:hover{color:var(--text);}
h1{font-size:24px;font-weight:700;margin-bottom:6px;}
.sub{color:var(--dim);font-size:14px;margin-bottom:24px;line-height:1.5;}
.panel{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:22px;margin-bottom:18px;}
.label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-bottom:10px;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
.src{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s;}
.src:hover{border-color:var(--accent);}
.src.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.src .t{font-size:13px;font-weight:600;}
.src .d{font-size:11px;color:var(--dim);margin-top:3px;line-height:1.35;}
.drop{border:2px dashed var(--border);border-radius:12px;padding:26px;text-align:center;color:var(--dim);cursor:pointer;transition:border-color .15s,background .15s;}
.drop:hover,.drop.over{border-color:var(--accent);background:rgba(35,131,226,.06);color:var(--text);}
.drop strong{color:var(--text);}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-h);}
.btn-ghost{background:transparent;color:var(--dim);border:1px solid var(--border);}
.btn-ghost:hover{color:var(--text);}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.actions{display:flex;gap:10px;align-items:center;margin-top:16px;flex-wrap:wrap;}
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dim);margin-top:12px;cursor:pointer;}
.bar{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-top:16px;}
.bar>i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);}
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;}
.pill{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--bg3);color:var(--dim);}
.pill.coll{background:rgba(35,131,226,.16);color:#79b8ff;}
.pill.ok{background:rgba(15,123,108,.2);color:#4fd1c5;}
.pill.err{background:rgba(224,62,62,.18);color:#f28b82;}
.pill.run{background:rgba(217,115,13,.18);color:#e3a15b;}
select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;}
.warnings{font-size:12px;color:var(--warn);margin-top:10px;line-height:1.5;}
.report{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;}
.stat{background:var(--bg3);border-radius:10px;padding:12px;text-align:center;}
.stat .n{font-size:22px;font-weight:700;}
.stat .l{font-size:11px;color:var(--dim);margin-top:2px;}
.error{color:var(--danger);font-size:13px;margin-top:10px;}
.x-cloak,[x-cloak]{display:none!important;}
</style>
</head>
<body x-data="importWizard()" x-init="init()">
<div class="wrap">
<div class="brand">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
<a href="/">FlowDeck</a> · Import
</div>
<h1>Importer des données</h1>
<p class="sub">Notes, vaults, bases de données, documents ou signets — choisissez une source ou laissez la détection automatique.</p>
<div class="panel">
<div class="label">1 · Source</div>
<div class="grid">
<div class="src" :class="{selected: sourceId===''}" @click="sourceId=''">
<div class="t">✨ Détection auto</div>
<div class="d">Reconnaît le format d'après le fichier.</div>
</div>
<template x-for="s in sources" :key="s.source_id">
<div class="src" :class="{selected: sourceId===s.source_id}" @click="sourceId=s.source_id">
<div class="t" x-text="s.label"></div>
<div class="d" x-text="s.description"></div>
</div>
</template>
</div>
</div>
<div class="panel">
<div class="label">2 · Fichiers <span style="text-transform:none;font-weight:400">(sélection multiple possible)</span></div>
<div class="drop" :class="{over: dragOver}"
@click="$refs.input.click()"
@dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false"
@drop.prevent="onDrop($event)">
<span>Glissez un ou plusieurs fichiers ici ou <strong>cliquez pour parcourir</strong><br><small>Markdown, .zip, .csv, .tsv, .xlsx, .json, .html, .docx, .pdf, .ics, .opml…</small></span>
</div>
<input x-ref="input" type="file" multiple style="display:none"
accept=".md,.markdown,.txt,.zip,.csv,.tsv,.xlsx,.xlsm,.json,.html,.htm,.docx,.docm,.pdf,.ics,.ical,.opml"
@change="onFiles($event)">
<template x-if="files.length">
<table style="margin-top:12px;">
<thead><tr><th>Fichier</th><th>Taille</th><th>Statut</th><th></th></tr></thead>
<tbody>
<template x-for="(f,i) in files" :key="i">
<tr>
<td x-text="f.name"></td>
<td x-text="humanSize(f.size)"></td>
<td><span class="pill" :class="f.status==='done'?'ok':(f.status==='error'?'err':(f.status==='running'?'run':''))" x-text="f.status"></span></td>
<td><button class="btn btn-ghost" style="padding:2px 8px" @click="files.splice(i,1)" :disabled="busy">✕</button></td>
</tr>
</template>
</tbody>
</table>
</template>
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:12px;">
<label class="check" style="margin:0">Mode de ré-import :
<select x-model="mode">
<option value="skip">Ignorer l'existant</option>
<option value="update">Mettre à jour (re-sync)</option>
<option value="duplicate">Toujours dupliquer</option>
</select>
</label>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="doPreview()" :disabled="!files.length || busy">Aperçu (dry-run)</button>
<button class="btn btn-primary" @click="doImport()" :disabled="!files.length || busy">Importer</button>
</div>
<div class="bar" x-show="busy || progress>0"><i :style="'width:'+progress+'%'"></i></div>
<div class="error" x-show="error" x-text="error"></div>
</div>
<div class="panel">
<div class="label">Ou · importer une page web (URL)</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" style="flex:1;min-width:260px;" x-model="urlValue" placeholder="https://exemple.com/article" @keydown.enter="doUrl()">
<button class="btn btn-ghost" @click="doUrl()" :disabled="!urlValue || busy">Importer l'URL</button>
</div>
<div class="error" x-show="urlError" x-text="urlError"></div>
</div>
<div class="panel">
<div class="label">Ou · importer depuis une forge</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select x-model="forgeProvider">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
<input type="text" style="width:150px;" x-model="forgeOwner" placeholder="owner">
<input type="text" style="width:150px;" x-model="forgeRepo" placeholder="repo">
<select x-model="forgeState">
<option value="all">Toutes</option>
<option value="open">Ouvertes</option>
<option value="closed">Fermées</option>
</select>
<button class="btn btn-ghost" @click="doForge('issues')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les issues</button>
<button class="btn btn-ghost" @click="doForge('repo')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les fichiers</button>
</div>
<div class="error" x-show="forgeError" x-text="forgeError"></div>
</div>
<div class="panel" x-show="preview" x-cloak>
<div class="label">Aperçu — <span x-text="preview && preview.source_label"></span></div>
<table>
<thead><tr><th>Titre</th><th>Type</th><th>Éléments</th><th>Colonnes</th></tr></thead>
<tbody>
<template x-for="(p,i) in (preview ? preview.pages : [])" :key="i">
<tr>
<td x-text="p.title"></td>
<td><span class="pill" :class="{coll: p.type==='collection'}" x-text="p.type==='collection' ? 'database' : 'page'"></span></td>
<td x-text="p.type==='collection' ? (p.rows+' lignes') : (p.blocks+' blocs')"></td>
<td>
<template x-for="col in (p.schema||[])" :key="col.name">
<span style="display:inline-flex;align-items:center;gap:4px;margin:2px 6px 2px 0;">
<span x-text="col.name" style="font-size:12px;"></span>
<select @change="mapping[col.name]=$event.target.value">
<template x-for="t in types" :key="t">
<option :value="t" :selected="t===col.type" x-text="t"></option>
</template>
</select>
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
<div class="warnings" x-show="preview && preview.warnings && preview.warnings.length">
<template x-for="(w,i) in (preview ? preview.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
</div>
</div>
<div class="panel" x-show="report" x-cloak>
<div class="label">Rapport d'import</div>
<div class="report">
<div class="stat"><div class="n" x-text="report ? report.pages_created : 0"></div><div class="l">Pages créées</div></div>
<div class="stat"><div class="n" x-text="report ? report.pages_updated : 0"></div><div class="l">Mises à jour</div></div>
<div class="stat"><div class="n" x-text="report ? report.collections_created : 0"></div><div class="l">Collections</div></div>
<div class="stat"><div class="n" x-text="report ? report.rows_created : 0"></div><div class="l">Lignes</div></div>
<div class="stat"><div class="n" x-text="report ? report.attachments : 0"></div><div class="l">Fichiers</div></div>
<div class="stat"><div class="n" x-text="report ? report.skipped : 0"></div><div class="l">Ignorés</div></div>
<div class="stat"><div class="n" x-text="report ? (report.errors ? report.errors.length : 0) : 0"></div><div class="l">Erreurs</div></div>
</div>
<div class="warnings" x-show="report && report.warnings && report.warnings.length">
<template x-for="(w,i) in (report ? report.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
</div>
<div class="warnings" x-show="report && report.errors && report.errors.length">
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
</div>
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
<a class="btn btn-primary" href="/workspaces">Retour au workspace</a>
</div>
</div>
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
</body>
</html>