fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
+123
-15
@@ -433,9 +433,12 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
padding: 6px 6px; margin: 0 2px;
|
||||
}
|
||||
.nav-icon-btn {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px; border-radius: 6px;
|
||||
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
|
||||
border: none; background: none; cursor: pointer; font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
|
||||
@@ -447,6 +450,91 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
/* Collapse button on the right */
|
||||
.nav-icon-btn.collapse-btn { margin-left: auto; }
|
||||
|
||||
/* ── Collapsible tabs (Home / Chat / Meeting / Inbox) ──
|
||||
The active tab expands into a pill showing icon + label, the inactive ones
|
||||
collapse to the bare icon so the row stays compact. */
|
||||
.nav-tab-btn {
|
||||
gap: 0;
|
||||
padding: 6px;
|
||||
min-width: 30px;
|
||||
justify-content: center;
|
||||
border-radius: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-tab-btn .nav-tab-label {
|
||||
display: none;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-tab-btn.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
gap: 7px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
}
|
||||
.nav-tab-btn.active .nav-tab-label { display: inline-block; }
|
||||
.nav-tab-badge {
|
||||
position: absolute;
|
||||
top: -1px; right: -1px;
|
||||
min-width: 15px; height: 15px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--text-dim);
|
||||
color: var(--bg-primary);
|
||||
font-size: 10px; font-weight: 700; line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-tab-badge-accent { background: #E03E3E; color: #fff; }
|
||||
.nav-tab-btn.active .nav-tab-badge { display: none; }
|
||||
|
||||
/* Tab panels — one replaces the whole sidebar content at a time */
|
||||
.sidebar-tab-panel { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sb-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
.sb-panel-title { font-size: 12px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sb-panel-count { color: var(--text-secondary); font-weight: 600; }
|
||||
.sb-panel-action {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 22px; height: 22px; padding: 0 5px;
|
||||
border: none; border-radius: 5px; background: none;
|
||||
color: var(--text-secondary); font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.sb-panel-action:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.sb-filter-row { display: flex; gap: 4px; padding: 2px 8px 6px; }
|
||||
.sb-filter-chip {
|
||||
padding: 3px 10px; border: 1px solid var(--border); border-radius: 12px;
|
||||
background: none; color: var(--text-secondary);
|
||||
font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.sb-filter-chip.active { background: var(--bg-active); color: var(--text-primary); border-color: transparent; }
|
||||
.sb-quick-actions { display: flex; flex-direction: column; gap: 2px; padding: 2px 6px 8px; }
|
||||
.sb-quick-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 8px; border: none; border-radius: 6px; background: none;
|
||||
color: var(--text-secondary); font-size: 12px; text-align: left; cursor: pointer;
|
||||
}
|
||||
.sb-quick-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.sb-hidden-input { display: none; }
|
||||
.sb-agent-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; }
|
||||
.sb-agent-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--border);
|
||||
background: var(--bg-hover); cursor: pointer; font-size: 15px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sb-agent-avatar:hover { border-color: var(--accent); }
|
||||
.sb-item-meta { margin-left: auto; font-size: 11px; color: var(--text-dim); flex-shrink: 0; }
|
||||
.sb-item-sub {
|
||||
display: block; font-size: 11px; color: var(--text-dim);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sb-item-unread { background: rgba(35, 131, 226, .10); }
|
||||
|
||||
/* Empty hint */
|
||||
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
|
||||
.sidebar-item.empty-hint:hover { background: none; }
|
||||
@@ -1042,6 +1130,11 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Conteneur standard des pages listes (Library, Trash, …) — largeur unique
|
||||
1100px, centrée. Défini ici (global) et non dans le <style> de
|
||||
library.html, sinon /trash rendait la classe sans style = pleine largeur. */
|
||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
@@ -3779,11 +3872,14 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
Database Table View (Notion-style)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
|
||||
données) s'alignent à gauche, au ras de la marge, à la même largeur
|
||||
que Library (1100px). */
|
||||
.database-table-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 96px;
|
||||
margin-top: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -4297,6 +4393,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.database-table-container { padding: 0 16px; }
|
||||
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||
.db-th-property { min-width: 120px; }
|
||||
.db-td-cell { min-width: 120px; }
|
||||
}
|
||||
@@ -4304,22 +4401,27 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
/* ── Database Side Peek Panel ── */
|
||||
.db-side-peek {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 560px; height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
|
||||
z-index: 900;
|
||||
width: 560px; max-width: 90vw; height: 100vh;
|
||||
background: var(--bg-modal);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 32px rgba(0,0,0,0.25);
|
||||
z-index: 500;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.db-side-peek-resize {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 6px; height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
.db-side-peek.open { transform: translateX(0); }
|
||||
/* Poignée partagée des side peeks (bases) — les autres peeks portent la
|
||||
même géométrie en style en ligne. */
|
||||
.peek-resize-handle {
|
||||
position: absolute; left: -4px; top: 0;
|
||||
width: 8px; height: 100%; cursor: col-resize;
|
||||
z-index: 501;
|
||||
}
|
||||
.db-side-peek-resize:hover { background: var(--accent, #2383E2); }
|
||||
.peek-resize-handle:hover { background: var(--accent, #2383E2); }
|
||||
.db-side-peek-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.db-side-peek-close, .db-side-peek-full {
|
||||
@@ -4329,7 +4431,13 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
font-size: 18px; color: var(--text-dim);
|
||||
}
|
||||
.db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); }
|
||||
.db-side-peek iframe { flex: 1; }
|
||||
.db-side-peek-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.db-side-peek-loading { text-align: center; padding: 40px; color: var(--text-dim); }
|
||||
.db-side-peek iframe { flex: 1; min-height: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||
}
|
||||
|
||||
/* ─── Simple Table block (v4.8.0) ─── */
|
||||
.ftable-editor { flex: 1; min-width: 0; min-height: 0; padding: 4px 2px; }
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — My Tasks (tableau de bord transverse façon Notion)
|
||||
* Préfixe `mt-`, conteneur `#my-tasks-app` (rendu par app/routers/my_tasks.py,
|
||||
* comportement par static/js/my_tasks.js).
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.my-tasks-app { max-width: 1100px; margin: 0 auto; padding: 20px 24px 64px; }
|
||||
|
||||
/* ── En-tête ──────────────────────────────────────────────────────────── */
|
||||
.mt-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.mt-head h1 {
|
||||
margin: 0; font-size: 24px; font-weight: 700; color: var(--text-primary);
|
||||
}
|
||||
.mt-count-total { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
/* ── Barre d'outils ─────────────────────────────────────────────────── */
|
||||
.mt-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
||||
justify-content: space-between; margin-bottom: 10px;
|
||||
}
|
||||
.mt-toolbar-left,
|
||||
.mt-toolbar-right {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
}
|
||||
.mt-view-tabs { display: flex; gap: 2px; align-items: center; }
|
||||
.mt-view-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; font-size: 13px; font-weight: 500;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
border: none; border-radius: 6px; cursor: pointer;
|
||||
text-decoration: none; white-space: nowrap; transition: all .12s;
|
||||
}
|
||||
.mt-view-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.mt-view-tab.active { color: var(--text-primary); background: var(--bg-active); }
|
||||
.mt-select {
|
||||
font: inherit; font-size: 13px; padding: 5px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
}
|
||||
.mt-toggle {
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
||||
}
|
||||
.mt-new-btn,
|
||||
.mt-empty-btn {
|
||||
font: inherit; font-size: 13px; font-weight: 600;
|
||||
padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer;
|
||||
text-decoration: none; display: inline-block;
|
||||
background: var(--accent, #2383e2); color: #fff;
|
||||
}
|
||||
.mt-new-btn:hover,
|
||||
.mt-empty-btn:hover { filter: brightness(1.08); }
|
||||
|
||||
/* ── Sources connectées ─────────────────────────────────────────────── */
|
||||
.mt-sources {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
padding: 8px 0 14px; margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-sources-label { font-size: 12px; color: var(--text-dim, #999); margin-right: 4px; }
|
||||
.mt-source-chip {
|
||||
font-size: 12px; padding: 3px 9px; border-radius: 20px; text-decoration: none;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
color: var(--text, #333); background: var(--bg-primary, #fff);
|
||||
}
|
||||
.mt-source-chip.pending { border-style: dashed; color: var(--text-dim, #999); }
|
||||
.mt-source-chip:hover { border-color: var(--accent, #2383e2); }
|
||||
.mt-sources-warn { font-size: 12px; color: #c9820a; }
|
||||
|
||||
/* ── Vue tableau ──────────────────────────────────────────────────────────
|
||||
* Lignes en `div` (et non `<table>`) : meme choix que Library, qui le permet
|
||||
* d'afficher les actions de ligne UNIQUEMENT au survol (`.hover-only`).
|
||||
*/
|
||||
.mt-table { width: 100%; }
|
||||
.mt-thead {
|
||||
display: flex; align-items: center; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-th {
|
||||
display: flex; align-items: center; padding: 6px 8px;
|
||||
font-size: 12px; font-weight: 500; color: var(--text-dim);
|
||||
white-space: nowrap; user-select: none;
|
||||
}
|
||||
.mt-row {
|
||||
display: flex; align-items: center; height: 38px;
|
||||
border-bottom: 1px solid var(--border); transition: background .1s;
|
||||
}
|
||||
.mt-row:last-child { border-bottom: none; }
|
||||
.mt-row:hover { background: var(--bg-hover); }
|
||||
.mt-cell {
|
||||
display: flex; align-items: center; padding: 0 8px;
|
||||
height: 100%; overflow: hidden;
|
||||
}
|
||||
.mt-cell-name { flex: 1; min-width: 0; gap: 6px; }
|
||||
.mt-cell-status,
|
||||
.mt-cell-due { width: 165px; flex-shrink: 0; }
|
||||
.mt-cell-src { width: 210px; flex-shrink: 0; }
|
||||
.mt-icon {
|
||||
font-size: 15px; flex-shrink: 0; width: 20px; text-align: center;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mt-title-text {
|
||||
font-size: 13px; color: var(--text-primary); cursor: text;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
min-width: 0; flex: 1 1 auto;
|
||||
}
|
||||
.mt-title-text:hover { text-decoration: underline; }
|
||||
.mt-title-input {
|
||||
font: inherit; width: 100%; padding: 3px 6px; border-radius: 4px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
}
|
||||
.mt-status-select,
|
||||
.mt-due-input {
|
||||
font: inherit; font-size: 13px; padding: 4px 6px; border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
max-width: 100%; width: 100%;
|
||||
}
|
||||
.mt-due-overdue { border-color: #e05252; color: #e05252; }
|
||||
.mt-due-today { border-color: #c9820a; color: #c9820a; font-weight: 600; }
|
||||
.mt-chip {
|
||||
font-size: 12px; padding: 3px 8px; border-radius: 5px; text-decoration: none;
|
||||
background: var(--bg-hover); color: var(--text-secondary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-chip:hover { color: var(--accent); }
|
||||
.mt-static { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
/* ── Bouton « Open » + actions visibles au survol ──────────────────────────
|
||||
* Mêmes valeurs que `.btn-open` / `.hover-only` de Library
|
||||
* (`app/templates/library.html`) : même aspect, même révélation au survol.
|
||||
*/
|
||||
.mt-hover-only { opacity: 0; transition: opacity .12s; flex-shrink: 0; }
|
||||
.mt-row:hover .mt-hover-only,
|
||||
.mt-row:focus-within .mt-hover-only { opacity: 1; }
|
||||
.mt-open-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 11px; font-weight: 500; color: var(--text-primary);
|
||||
background: var(--bg-tertiary); cursor: pointer; white-space: nowrap;
|
||||
transition: all .1s; flex-shrink: 0; text-decoration: none; margin-left: auto;
|
||||
}
|
||||
.mt-open-btn:hover { background: var(--bg-active); }
|
||||
.mt-open-btn svg { width: 12px; height: 12px; }
|
||||
@media (hover: none) {
|
||||
/* Écrans tactiles : pas de survol, l'action doit rester visible. */
|
||||
.mt-hover-only { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Side peek (panneau latéral, sur le modèle de Library) ───────────────
|
||||
* `?embed=1` charge l'éditeur du document dans l'iframe, comme le peek de
|
||||
* Library : on lit la tâche sans quitter le tableau de bord.
|
||||
*/
|
||||
.mt-peek {
|
||||
position: fixed; top: 0; right: 0; width: 560px; max-width: 90vw;
|
||||
height: 100vh; z-index: 500; display: flex; flex-direction: column;
|
||||
background: var(--bg-modal); border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, .25);
|
||||
transform: translateX(100%); transition: transform .2s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
.mt-peek.open { transform: translateX(0); }
|
||||
.mt-peek-handle {
|
||||
position: absolute; left: -4px; top: 0; width: 8px; height: 100%;
|
||||
cursor: col-resize; z-index: 501;
|
||||
}
|
||||
.mt-peek-head {
|
||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-peek-title {
|
||||
flex: 1; min-width: 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--text-primary); display: flex; align-items: center; gap: 8px;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-peek-btn {
|
||||
background: transparent; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 6px; border-radius: 4px;
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.mt-peek-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.mt-peek-btn svg { width: 15px; height: 15px; }
|
||||
.mt-peek-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.mt-peek-body iframe { flex: 1; width: 100%; border: none; min-height: 0; }
|
||||
.mt-peek-loading {
|
||||
padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.mt-peek { width: 100vw; max-width: 100vw; }
|
||||
}
|
||||
|
||||
/* ── Vue kanban ─────────────────────────────────────────────────────── */
|
||||
.mt-board { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; }
|
||||
.mt-col {
|
||||
min-width: 240px; flex: 1 0 240px; padding: 10px; border-radius: 10px;
|
||||
background: var(--bg-secondary, #f7f7f5);
|
||||
}
|
||||
.mt-col-head { font-size: 12px; font-weight: 600; color: var(--text-dim, #777); margin-bottom: 8px; }
|
||||
.mt-count { opacity: .6; font-weight: 400; }
|
||||
.mt-col-body { display: flex; flex-direction: column; gap: 6px; min-height: 60px; }
|
||||
.mt-card {
|
||||
display: flex; flex-direction: column; gap: 4px; padding: 9px 10px;
|
||||
border: 1px solid var(--border, #e3e3e0); border-radius: 7px;
|
||||
background: var(--bg-primary, #fff); font-size: 13px; cursor: grab;
|
||||
}
|
||||
.mt-card:active { cursor: grabbing; opacity: .7; }
|
||||
.mt-card-title { font-weight: 500; }
|
||||
.mt-card-due,
|
||||
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
|
||||
.mt-card-src { opacity: .8; }
|
||||
|
||||
/* ── Vue calendrier ─────────────────────────────────────────────────── */
|
||||
.mt-cal {
|
||||
border: 1px solid var(--border, #eee); border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.mt-cal-head {
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
padding: 10px; border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-cal-title { font-size: 14px; font-weight: 600; text-transform: capitalize; }
|
||||
.mt-cal-nav {
|
||||
padding: 2px 9px; border: 1px solid var(--border, #e3e3e0); border-radius: 5px;
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
font-size: 15px; cursor: pointer;
|
||||
}
|
||||
.mt-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||
.mt-cal-dow {
|
||||
padding: 6px 0; text-align: center; font-size: 11px;
|
||||
color: var(--text-dim, #999); border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-cal-cell {
|
||||
min-height: 92px; padding: 5px 6px; font-size: 12px;
|
||||
border-right: 1px solid var(--border, #f0f0f0);
|
||||
border-bottom: 1px solid var(--border, #f0f0f0);
|
||||
}
|
||||
.mt-cal-cell.out { opacity: .4; }
|
||||
.mt-cal-cell.today { background: var(--bg-hover, rgba(35, 131, 226, .07)); }
|
||||
.mt-cal-day { margin-bottom: 4px; font-size: 11px; color: var(--text-dim, #999); }
|
||||
.mt-cal-ev:hover { background: var(--bg-hover); }
|
||||
.mt-cal-ev:hover .mt-cal-open { opacity: 1; }
|
||||
|
||||
/* ── Calendrier : sélecteur de vue + 4 vues supplémentaires ───────────── */
|
||||
.mt-cal-body { padding: 8px; }
|
||||
.mt-cal-tabs {
|
||||
display: flex; gap: 2px; padding: 6px 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-tab {
|
||||
padding: 5px 11px; font-size: 12.5px; font-weight: 500;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.mt-cal-tab:hover { color: var(--text-primary); }
|
||||
.mt-cal-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Événement : titre + bouton d'ouverture révélé au survol. */
|
||||
.mt-cal-ev {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-bottom: 3px; padding: 2px 5px; border-radius: 4px;
|
||||
background: var(--bg-hover); font-size: 12px; min-width: 0;
|
||||
}
|
||||
.mt-cal-ev.drag { cursor: grab; }
|
||||
.mt-cal-ev-text {
|
||||
flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mt-cal-open {
|
||||
flex-shrink: 0; display: inline-flex; align-items: center;
|
||||
padding: 1px; border: none; border-radius: 3px; cursor: pointer;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
}
|
||||
.mt-cal-open svg { width: 11px; height: 11px; display: block; }
|
||||
.mt-cal-open:hover { background: var(--bg-active); color: var(--text-primary); }
|
||||
|
||||
/* Vue année : 12 mini-mois. */
|
||||
.mt-cal-year {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px; padding: 10px;
|
||||
}
|
||||
.mt-cal-mini-title {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
text-transform: capitalize; margin-bottom: 5px;
|
||||
}
|
||||
.mt-cal-mini-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||
}
|
||||
.mt-cal-mini-dow {
|
||||
font-size: 9px; color: var(--text-dim); text-align: center; padding: 1px 0;
|
||||
}
|
||||
.mt-cal-mini-day {
|
||||
font: inherit; font-size: 10.5px; padding: 3px 0; border: none;
|
||||
border-radius: 4px; cursor: pointer; position: relative;
|
||||
background: transparent; color: var(--text-primary);
|
||||
}
|
||||
.mt-cal-mini-day:hover { background: var(--bg-hover); }
|
||||
.mt-cal-mini-day.out { visibility: hidden; }
|
||||
.mt-cal-mini-day.today { background: var(--accent); color: #fff; }
|
||||
.mt-cal-mini-day.busy::after {
|
||||
content: ''; position: absolute; left: 50%; bottom: 1px;
|
||||
width: 3px; height: 3px; margin-left: -1.5px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.mt-cal-mini-day.today.busy::after { background: #fff; }
|
||||
|
||||
/* Vue semaine : 7 colonnes. */
|
||||
.mt-cal-week {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||
background: var(--border); min-height: 180px;
|
||||
}
|
||||
.mt-cal-wcol { background: var(--bg-primary); padding: 6px; min-width: 0; }
|
||||
.mt-cal-wcol.today { background: var(--bg-hover); }
|
||||
.mt-cal-wcol-head {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||
margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-wcol-body { display: flex; flex-direction: column; gap: 3px; }
|
||||
.mt-cal-wempty { font-size: 12px; color: var(--text-dim); text-align: center; }
|
||||
|
||||
/* Vue jour : liste détaillée. */
|
||||
.mt-cal-dayview { padding: 10px 12px 14px; }
|
||||
.mt-cal-dayview-head {
|
||||
font-size: 12px; color: var(--text-secondary); margin-bottom: 10px;
|
||||
}
|
||||
.mt-cal-dayview-empty {
|
||||
font-size: 13px; color: var(--text-dim); padding: 26px 0; text-align: center;
|
||||
}
|
||||
.mt-cal-task {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-task:hover { background: var(--bg-hover); }
|
||||
.mt-cal-task-title {
|
||||
flex: 1; min-width: 0; font-size: 13px; color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-cal-task-src, .mt-cal-task-status { font-size: 12px; color: var(--text-dim); }
|
||||
.mt-cal-task-status { width: 130px; flex-shrink: 0; }
|
||||
|
||||
/* Vue planning : colonnes = statuts, lignes = jours. */
|
||||
.mt-cal-plan { overflow-x: auto; max-height: 60vh; overflow-y: auto; }
|
||||
.mt-cal-plan-row { display: flex; align-items: stretch; min-width: 520px; }
|
||||
.mt-cal-plan-row.head { position: sticky; top: 0; z-index: 2; background: var(--bg-primary); }
|
||||
.mt-cal-plan-gutter {
|
||||
width: 44px; flex-shrink: 0; padding: 4px 6px;
|
||||
font-size: 11px; color: var(--text-dim); text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-plan-col-title {
|
||||
flex: 1; min-width: 0; padding: 4px 8px;
|
||||
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-cal-plan-cell {
|
||||
flex: 1; min-width: 0; padding: 3px 5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-plan-row.today .mt-cal-plan-gutter {
|
||||
color: var(--accent); font-weight: 700;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.mt-cal-year { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── États vides ──────────────────────────────────────────────────────────
|
||||
* Bloc centré dans la surface disponible : invite de conversion
|
||||
* (`.my-tasks-empty`, rendu serveur) comme états vides du tableau de bord
|
||||
* (`.mt-empty`, rendu client). Même gabarit pour que les deux se ressemblent.
|
||||
*/
|
||||
.my-tasks-empty,
|
||||
.mt-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; padding: 72px 24px 80px;
|
||||
max-width: 620px; margin: 0 auto;
|
||||
}
|
||||
.my-tasks-empty-icon,
|
||||
.mt-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.my-tasks-empty h2,
|
||||
.mt-empty h2 {
|
||||
margin: 0 0 10px; font-size: 19px; font-weight: 600; color: var(--text-primary, #333);
|
||||
}
|
||||
.my-tasks-empty p,
|
||||
.mt-empty p {
|
||||
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.my-tasks-empty-hint,
|
||||
.mt-empty-hint {
|
||||
font-size: 13px; color: var(--text-dim, #888);
|
||||
}
|
||||
.my-tasks-empty-steps,
|
||||
.mt-empty-steps {
|
||||
margin: 20px 0 0; padding: 14px 18px; list-style: none;
|
||||
display: inline-flex; flex-direction: column; gap: 8px; text-align: left;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
border-radius: 10px; background: var(--bg-secondary, #fafafa);
|
||||
font-size: 13px; color: var(--text-secondary, #666);
|
||||
}
|
||||
.my-tasks-empty-steps li,
|
||||
.mt-empty-steps li { display: flex; align-items: flex-start; gap: 9px; }
|
||||
.my-tasks-empty-steps .n,
|
||||
.mt-empty-steps .n {
|
||||
flex-shrink: 0; width: 20px; height: 20px; margin-top: -1px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-strong, #d3d3d0);
|
||||
font-size: 11px; font-weight: 700; color: var(--text-dim, #999);
|
||||
}
|
||||
.my-tasks-empty-btn,
|
||||
.mt-empty-btn {
|
||||
display: inline-block; margin-top: 22px; padding: 9px 20px; border-radius: 8px;
|
||||
background: var(--accent, #2383e2); color: #fff;
|
||||
font-size: 14px; font-weight: 600; text-decoration: none; border: none;
|
||||
cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.my-tasks-empty-btn:hover,
|
||||
.mt-empty-btn:hover { background: var(--accent-hover, #1a6bc0); filter: none; }
|
||||
|
||||
/* ── Modale « nouvelle tâche » (multi-destinations) ─────────────────── */
|
||||
.mt-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000; padding-top: 12vh;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
background: rgba(0, 0, 0, .4);
|
||||
}
|
||||
.mt-modal {
|
||||
width: 460px; max-width: 92vw; overflow: hidden; border-radius: 12px;
|
||||
background: var(--bg-primary, #fff); box-shadow: 0 18px 60px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.mt-modal-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-modal-close {
|
||||
border: none; background: none; color: var(--text-dim, #888);
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.mt-modal-body {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
max-height: 55vh; overflow-y: auto; padding: 16px;
|
||||
}
|
||||
.mt-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 12px; color: var(--text-dim, #888);
|
||||
}
|
||||
.mt-field input {
|
||||
font: inherit; font-size: 14px; padding: 7px 9px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
}
|
||||
.mt-modal-group {
|
||||
margin-top: 4px; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-dim, #999);
|
||||
}
|
||||
.mt-dest {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||
border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.mt-dest:hover { background: var(--bg-hover, rgba(0, 0, 0, .04)); }
|
||||
.mt-modal-foot {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-btn-cancel,
|
||||
.mt-btn-primary {
|
||||
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||
color: var(--text, #333); cursor: pointer;
|
||||
}
|
||||
.mt-btn-primary {
|
||||
border-color: transparent; background: var(--accent, #2383e2);
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Modale « Convertir en base de tâches » (task_db_link.js) ─────────── */
|
||||
.tdb-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000; padding-top: 10vh;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
background: rgba(0, 0, 0, .4);
|
||||
}
|
||||
.tdb-modal {
|
||||
width: 520px; max-width: 94vw; overflow: hidden; border-radius: 12px;
|
||||
background: var(--bg-modal, var(--bg-primary, #fff));
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, .25); color: var(--text-primary, #333);
|
||||
}
|
||||
.tdb-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.tdb-close {
|
||||
border: none; background: none; color: var(--text-dim, #888);
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.tdb-intro {
|
||||
padding: 12px 16px 0; font-size: 12.5px; line-height: 1.5;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.tdb-intro a { color: var(--accent, #2383e2); }
|
||||
.tdb-body { display: flex; flex-direction: column; gap: 14px; padding: 14px 16px; }
|
||||
.tdb-role-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; }
|
||||
.tdb-role-head label { font-size: 13px; font-weight: 600; }
|
||||
.tdb-role-hint { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||
.tdb-role-body { display: flex; gap: 8px; }
|
||||
.tdb-select,
|
||||
.tdb-new-name {
|
||||
font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text-primary, #333);
|
||||
}
|
||||
.tdb-select { flex: 1 1 auto; min-width: 0; }
|
||||
.tdb-new-name { flex: 1 1 auto; min-width: 0; }
|
||||
.tdb-warn {
|
||||
margin: 0 16px 4px; padding: 8px 10px; border-radius: 6px; font-size: 12px;
|
||||
background: rgba(201, 130, 10, .12); color: #c9820a;
|
||||
}
|
||||
.tdb-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||
}
|
||||
.tdb-spacer { flex: 1; }
|
||||
.tdb-btn,
|
||||
.tdb-btn-primary,
|
||||
.tdb-btn-danger {
|
||||
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||
color: var(--text-primary, #333); cursor: pointer;
|
||||
}
|
||||
.tdb-btn-primary {
|
||||
border-color: transparent; background: var(--accent, #2383e2);
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
.tdb-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tdb-btn-danger { color: #e5484d; border-color: rgba(229, 72, 77, .4); }
|
||||
|
||||
/* ── Page « /db » : liste des bases du workspace ─────────────────────────
|
||||
* En-tête recentré (page de destination du bouton « Ouvrir mes bases »),
|
||||
* grille de cartes à gauche pour rester lisible sur les longues listes.
|
||||
*/
|
||||
.db-index { padding: 40px 24px 64px; max-width: 1100px; margin: 0 auto; }
|
||||
.db-index-head {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-wrap: wrap; gap: 10px; margin-bottom: 6px; text-align: center;
|
||||
}
|
||||
.db-index-head h1 { margin: 0; font-size: 24px; }
|
||||
.db-index-ws {
|
||||
font-size: 12px; color: var(--text-dim, #999);
|
||||
padding: 2px 9px; border-radius: 20px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.db-index-sub {
|
||||
max-width: 640px; margin: 0 auto 26px; text-align: center;
|
||||
font-size: 13.5px; line-height: 1.6; color: var(--text-secondary, #666);
|
||||
}
|
||||
.db-index-sub a { color: var(--accent, #2383e2); }
|
||||
.db-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px;
|
||||
}
|
||||
.db-card {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 16px 16px; border-radius: 12px; text-decoration: none;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
transition: border-color .12s, box-shadow .12s, transform .12s;
|
||||
}
|
||||
.db-card:hover {
|
||||
border-color: var(--accent, #2383e2);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, .09);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.db-card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.db-card-icon { font-size: 19px; }
|
||||
.db-card-name {
|
||||
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.db-card-badge {
|
||||
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 20px;
|
||||
}
|
||||
.db-card-badge.ok { background: rgba(0, 200, 100, .14); color: #00a35a; }
|
||||
.db-card-badge.warn { background: rgba(201, 130, 10, .14); color: #c9820a; }
|
||||
.db-card-meta { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||
.db-card-hint { font-size: 12px; line-height: 1.45; color: var(--text-secondary, #666); }
|
||||
|
||||
/* État vide de `/db` : bloc centré dans la surface disponible. */
|
||||
.db-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; padding: 72px 24px 80px;
|
||||
max-width: 620px; margin: 0 auto;
|
||||
}
|
||||
.db-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.db-empty h1 { margin: 0 0 10px; font-size: 19px; font-weight: 600; }
|
||||
.db-empty p {
|
||||
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.db-empty-hint { max-width: 520px; font-size: 13px; }
|
||||
.db-empty-hint code {
|
||||
padding: 1px 5px; border-radius: 4px; font-size: 12px;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .05));
|
||||
}
|
||||
|
||||
/* ── Bouton « Convertir en base de tâches » (barre de la base) ────────── */
|
||||
.db-task-db-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 11px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: transparent; color: var(--text-secondary, #555);
|
||||
font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.db-task-db-btn:hover {
|
||||
border-color: var(--accent, #2383e2);
|
||||
color: var(--accent, #2383e2);
|
||||
background: var(--bg-hover, rgba(35, 131, 226, .07));
|
||||
}
|
||||
.mt-source-fix {
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
|
||||
border: 1px solid rgba(201, 130, 10, .45); background: transparent;
|
||||
color: #c9820a; cursor: pointer;
|
||||
}
|
||||
.mt-source-fix:hover { background: rgba(201, 130, 10, .12); }
|
||||
.db-section-title {
|
||||
margin: 38px 0 4px; font-size: 15px; font-weight: 600; color: var(--text, #333);
|
||||
text-align: center;
|
||||
}
|
||||
.db-section-sub {
|
||||
max-width: 640px; margin: 0 auto 14px; text-align: center;
|
||||
font-size: 12.5px; line-height: 1.55; color: var(--text-dim, #888);
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
});
|
||||
|
||||
this._applyState = applyState;
|
||||
this._initResize();
|
||||
this.fetchProviders();
|
||||
this.fetchAgents();
|
||||
this.fetchSkills();
|
||||
@@ -86,6 +87,11 @@
|
||||
self.setComposerText(t);
|
||||
setTimeout(function(){ self.send(); }, 60);
|
||||
};
|
||||
// Open the panel on a given conversation (sidebar Chat history).
|
||||
window.fdAgent.openConversation = function(id){
|
||||
self.openAgent();
|
||||
self.loadConversation(id);
|
||||
};
|
||||
// Headless text generation (no tools) via /agent/generate — the clean
|
||||
// path used by "Ask AI" / "AI meeting note" / summarization. Sending a
|
||||
// plain write request through the tool-enabled conversation engine sends
|
||||
@@ -108,6 +114,79 @@
|
||||
};
|
||||
},
|
||||
|
||||
// ── Resizable panel width (drag handle on the left edge, persisted) ──
|
||||
_apMinWidth(){ return Math.min(360, window.innerWidth); },
|
||||
_apMaxWidth(){ return Math.max(this._apMinWidth(), Math.min(Math.round(window.innerWidth * 0.9), 1200)); },
|
||||
_apClampWidth(w){
|
||||
return Math.round(Math.max(this._apMinWidth(), Math.min(this._apMaxWidth(), w || 0)));
|
||||
},
|
||||
_apApplyWidth(w){
|
||||
var panel = document.getElementById('fd-agent-panel');
|
||||
if(!panel) return;
|
||||
var width = this._apClampWidth(w);
|
||||
panel.style.setProperty('--fd-agent-width', width + 'px');
|
||||
return width;
|
||||
},
|
||||
_initResize(){
|
||||
var self = this;
|
||||
var panel = document.getElementById('fd-agent-panel');
|
||||
var handle = panel && panel.querySelector('.fd-ap-resize');
|
||||
if(!panel || !handle) return;
|
||||
|
||||
// Restore the user width (clamped to the current viewport).
|
||||
try {
|
||||
var saved = parseInt(localStorage.getItem('fd_agent_width') || '', 10);
|
||||
if(saved) this._apApplyWidth(saved);
|
||||
} catch{ /* stockage indisponible */ }
|
||||
|
||||
var startX = 0, startWidth = 0, dragging = false;
|
||||
var persist = function(){
|
||||
try { localStorage.setItem('fd_agent_width', panel.style.getPropertyValue('--fd-agent-width')); } catch{ /* ignoré */ }
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', function(e){
|
||||
if(e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
try { handle.setPointerCapture(e.pointerId); } catch{ /* ignoré */ }
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.classList.add('fd-ap-resizing');
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e){
|
||||
if(!dragging) return;
|
||||
// The panel is anchored to the right edge → dragging left widens it.
|
||||
self._apApplyWidth(startWidth - (e.clientX - startX));
|
||||
});
|
||||
var endDrag = function(e){
|
||||
if(!dragging) return;
|
||||
dragging = false;
|
||||
document.body.classList.remove('fd-ap-resizing');
|
||||
persist();
|
||||
try { handle.releasePointerCapture(e.pointerId); } catch{ /* ignoré */ }
|
||||
};
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', endDrag);
|
||||
handle.addEventListener('dblclick', function(){
|
||||
self._apApplyWidth(470);
|
||||
persist();
|
||||
});
|
||||
// Keyboard a11y: ← / → adjust by 20px, Home resets to the default.
|
||||
handle.addEventListener('keydown', function(e){
|
||||
var step = e.shiftKey ? 60 : 20, cur = panel.offsetWidth;
|
||||
if(e.key === 'ArrowLeft'){ self._apApplyWidth(cur + step); }
|
||||
else if(e.key === 'ArrowRight'){ self._apApplyWidth(cur - step); }
|
||||
else if(e.key === 'Home'){ self._apApplyWidth(470); }
|
||||
else { return; }
|
||||
e.preventDefault();
|
||||
persist();
|
||||
});
|
||||
// Keep the width usable when the window shrinks / grows.
|
||||
window.addEventListener('resize', function(){
|
||||
self._apApplyWidth(panel.offsetWidth);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Open / close (robust) ──
|
||||
openAgent(){
|
||||
if(this.open) { this.refreshContext(); return; }
|
||||
@@ -1588,7 +1667,9 @@
|
||||
delete_page:'Page supprimée', delete_collection:'Collection supprimée',
|
||||
apply_template:'Template appliqué', create_sub_item:'Sous-élément créé',
|
||||
add_dependency:'Dépendance ajoutée', read_gitea_issues:'Issues Gitea lues',
|
||||
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées'
|
||||
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées',
|
||||
web_search:'Recherche web effectuée', fetch_url:'Page web lue',
|
||||
search_code:'Recherche GitHub effectuée'
|
||||
};
|
||||
// Build an action step object from an SSE event or a persisted action row.
|
||||
agentPanel._actionStep = function(ev){
|
||||
|
||||
+71
-3
@@ -252,6 +252,12 @@
|
||||
// managed by Alpine or HTMX keep their default behaviour.
|
||||
function shouldIntercept(a, evt) {
|
||||
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
|
||||
// Un handler plus profond a déjà traité le clic et appelé preventDefault
|
||||
// (onglets de vue, lien interne…). Sans ce test, le navigateur AND ce
|
||||
// navigateur SPA traitent le même clic : le composant rend son état, puis
|
||||
// `fdLoad` remplace la zone `.main-wrapper` par la réponse serveur — le
|
||||
// composant perd son DOM et la page finit vide.
|
||||
if (evt.defaultPrevented) return false;
|
||||
if (a.hasAttribute('data-no-nav')) return false;
|
||||
var target = (a.getAttribute('target') || '').toLowerCase();
|
||||
if (target && target !== '_self') return false;
|
||||
@@ -278,14 +284,30 @@
|
||||
}
|
||||
|
||||
var loading = false;
|
||||
var LOAD_WATCHDOG_MS = 15000;
|
||||
|
||||
function fdLoad(url, push) {
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
// Une navigation est déjà en vol : on l'ABANDONNE au profit d'un chargement
|
||||
// complet. Avant, ce clic etait simplement ignore (aucune URL, aucun
|
||||
// changement) — et si la promesse htmx ne se resolvait jamais, `loading`
|
||||
// restait a `true` indefiniment : plus aucune navigation ne repartait, la
|
||||
// page restait figee (ou a moitie peinte) jusqu'au hard refresh.
|
||||
if (loading) { window.location.href = url; return; }
|
||||
loading = true;
|
||||
var settled = false;
|
||||
function done() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
loading = false;
|
||||
clearTimeout(watchdog);
|
||||
}
|
||||
// Filet de securite : `loading` ne doit jamais pouvoir rester bloque.
|
||||
var watchdog = setTimeout(function () {
|
||||
if (settled) return;
|
||||
done();
|
||||
window.location.href = url;
|
||||
}, LOAD_WATCHDOG_MS);
|
||||
var p;
|
||||
try {
|
||||
p = window.htmx.ajax('GET', url, {
|
||||
@@ -361,7 +383,8 @@
|
||||
mw.style.opacity = '0';
|
||||
mw.style.pointerEvents = 'none';
|
||||
mw.setAttribute('x-ignore', '');
|
||||
var pending = mw.querySelectorAll('script[src]').length;
|
||||
var scripts = Array.prototype.slice.call(mw.querySelectorAll('script[src]'));
|
||||
var pending = scripts.length;
|
||||
var reveal = function () {
|
||||
if (!mw.isConnected) return;
|
||||
mw.style.opacity = '';
|
||||
@@ -380,7 +403,14 @@
|
||||
reveal();
|
||||
};
|
||||
var onScript = function (e) {
|
||||
if (!e.target || e.target.tagName !== 'SCRIPT') return;
|
||||
var node = e.target;
|
||||
// `load`/`error` en capture sur `document` voit TOUS les <script> de la
|
||||
// page, pas seulement ceux de la zone swappée : compter les uns et les
|
||||
// autres décomptait trop vite et montait l'arbre Alpine avant que les
|
||||
// scripts de la page n'aient tourné (page à moitié montée, composants
|
||||
// affiché à l'état brut). On ne compte donc que les noeuds attendus.
|
||||
if (!node || node.tagName !== 'SCRIPT') return;
|
||||
if (scripts.indexOf(node) === -1) return;
|
||||
if (--pending <= 0) done();
|
||||
};
|
||||
if (!pending) {
|
||||
@@ -481,3 +511,41 @@ function openCardDetail(id) {
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
|
||||
|
||||
// ── Side peek : poignée de redimensionnement partagée (v7.49.1) ──
|
||||
// Une seule implémentation pour les peeks de Library, du workspace local,
|
||||
// de My Tasks et des bases de données (le code copié trois fois dérivait :
|
||||
// celui de My Tasks ne capturait pas le pointeur et ne persistait rien).
|
||||
// La largeur choisie est commune à tous les peeks (localStorage fd_peek_width),
|
||||
// comme l'utilisateur ne voit qu'un seul panneau à la fois.
|
||||
window.fdWirePeekResize = function(panel, handle, onClose) {
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
try { handle.setPointerCapture(e.pointerId); } catch { /* pointeur fantôme */ }
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var w = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + (startX - e.clientX)));
|
||||
panel.style.width = w + 'px';
|
||||
});
|
||||
function stop(e) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
try { handle.releasePointerCapture(e.pointerId); } catch { /* déjà relâché */ }
|
||||
if (e && Math.abs(e.clientX - startX) < 3 && typeof onClose === 'function') onClose();
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* navigation privée */ }
|
||||
}
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', function() { dragging = false; document.body.style.userSelect = ''; });
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* navigation privée */ }
|
||||
};
|
||||
|
||||
+91
-40
@@ -238,6 +238,23 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(VIEW_ICONS[t]||VIEW_ICONS.table)+'</svg>';
|
||||
}
|
||||
|
||||
function taskDbButton(collection) {
|
||||
// Le libellé suit l'état réel de la base : convertie, à
|
||||
// configurer, ou pas encore convertie. Un simple ✓ était
|
||||
// introuvable pour l'utilisateur.
|
||||
var isTask = !!(collection && collection.is_task);
|
||||
var mapped = !!(collection && collection.task_assignee_prop &&
|
||||
collection.task_status_prop && collection.task_due_prop);
|
||||
var label = isTask ? (mapped ? '✓ Base de tâches' : '⚠ Configurer')
|
||||
: 'Convertir en base de tâches';
|
||||
var title = isTask
|
||||
? (mapped ? 'Cette base alimente My Tasks'
|
||||
: 'Base connectée mais mapping incomplet — reenregistrer')
|
||||
: 'Relier Assigné à / Statut / Échéance pour alimenter My Tasks';
|
||||
return '<button class="db-task-db-btn" data-task-db="' + collectionId +
|
||||
'" title="' + escHtml(title) + '">' + label + '</button>';
|
||||
}
|
||||
|
||||
function renderViewBar() {
|
||||
var h = '<div class="db-view-bar" style="padding:4px 0 8px"><div class="db-view-tabs">';
|
||||
if (!state.views.length) {
|
||||
@@ -251,7 +268,8 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
});
|
||||
h += '<button class="db-view-add" data-view-add="1" title="Add a view">+</button>';
|
||||
h += '</div>';
|
||||
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '<div class="db-view-actions">' + taskDbButton(state.collection) +
|
||||
'<button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
@@ -1264,51 +1282,84 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global Side Peek for Database Pages (auto-creates panel) ──
|
||||
function openPageInSidePeek(pageId) {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'db-side-peek';
|
||||
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
|
||||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||||
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
|
||||
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
|
||||
'</div>'+
|
||||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||||
document.body.appendChild(panel);
|
||||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||||
document.getElementById('db-side-peek-full').onclick = function(){
|
||||
var ifr = document.getElementById('db-side-peek-iframe');
|
||||
if (ifr && ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||||
};
|
||||
var rh = document.getElementById('db-side-peek-resize');
|
||||
rh.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var sx = e.clientX, sw = panel.offsetWidth;
|
||||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||||
document.body.style.cursor='col-resize';
|
||||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||||
};
|
||||
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
|
||||
// Le panneau vit dans _database_table.html (page de base ET bases
|
||||
// embarquées inline). Il doit être câblé SUR le document, jamais via
|
||||
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine
|
||||
// du panneau restaient inertes — clic sur une ligne = rien).
|
||||
var peekEl = document.getElementById('db-side-peek');
|
||||
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne
|
||||
// doit pas lever de ReferenceError : repli discret, comme l'ancien code.
|
||||
if (!peekEl) window.openPageInSidePeek = function() {};
|
||||
if (peekEl) {
|
||||
var peekFrame = document.getElementById('db-side-peek-iframe');
|
||||
var peekTitle = document.getElementById('db-side-peek-title');
|
||||
var peekLoading = document.getElementById('db-side-peek-loading');
|
||||
function closeDbPeek() {
|
||||
peekEl.classList.remove('open');
|
||||
document.getElementById('db-side-peek-iframe').src='about:blank';
|
||||
if (peekTitle) { peekTitle.textContent = ''; }
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
|
||||
// opened an unrelated page with the same id) — resolve the row's
|
||||
// content page first (lazy-created), then load the real editor.
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
var fr = document.getElementById('db-side-peek-iframe');
|
||||
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
|
||||
window.openPageInSidePeek = function(pageId) {
|
||||
if (!pageId) return;
|
||||
peekEl.classList.add('open');
|
||||
if (peekLoading) peekLoading.style.display = '';
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) iframe.src='about:blank';
|
||||
if (peekTitle) peekTitle.textContent = '';
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
if (!peekEl.classList.contains('open')) return;
|
||||
if (peekTitle) peekTitle.textContent = '';
|
||||
if (peekLoading) peekLoading.style.display = 'none';
|
||||
if (iframe) iframe.src = '/pages/'+pid;
|
||||
})
|
||||
.catch(function(){
|
||||
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
|
||||
// CSP d'Alpine les toasts ne s'affichent pas partout.
|
||||
if (iframe) {
|
||||
iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
|
||||
}
|
||||
peekEl.classList.add('open');
|
||||
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
|
||||
});
|
||||
};
|
||||
var pc = document.getElementById('db-side-peek-close');
|
||||
if (pc) pc.addEventListener('click', closeDbPeek);
|
||||
var pf = document.getElementById('db-side-peek-full');
|
||||
if (pf) pf.addEventListener('click', function() {
|
||||
if (peekFrame && peekFrame.src && peekFrame.src !== 'about:blank') {
|
||||
window.location.href = peekFrame.src;
|
||||
}
|
||||
});
|
||||
if (iframe) panel.style.display = 'flex';
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek();
|
||||
});
|
||||
document.addEventListener('pointerdown', function(e) {
|
||||
if (!peekEl.classList.contains('open')) return;
|
||||
if (peekEl.contains(e.target)) return;
|
||||
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
|
||||
closeDbPeek();
|
||||
});
|
||||
function wireDbPeekResize() {
|
||||
if (window.fdWirePeekResize) {
|
||||
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek);
|
||||
} else if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', wireDbPeekResize);
|
||||
}
|
||||
}
|
||||
wireDbPeekResize();
|
||||
}
|
||||
|
||||
function resolveRowContentPage(rowId){
|
||||
return fetch('/db/pages/'+rowId+'/open/api')
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
|
||||
.catch(function(){ return rowId; });
|
||||
.then(function(r){
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(d){
|
||||
if (!d || !d.page_id) throw new Error('page de contenu introuvable');
|
||||
return d.page_id;
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
+32
-36
@@ -965,13 +965,33 @@ _renderIcon(item) {
|
||||
newPage() { this.addNewPage(); },
|
||||
|
||||
// ── Side peek ──
|
||||
async openPeek(item) {
|
||||
// L'iframe est pilotée en JS : le build CSP d'Alpine n'évalue pas le
|
||||
// binding `:src` (l'iframe restait vide et aucun document n'apparaissait).
|
||||
// `about:blank` ferme explicitement le document précédent avant de charger
|
||||
// le nouveau : sans cela, même URL = pas de rechargement et l'ancien
|
||||
// document restait affiché.
|
||||
openPeek(item) {
|
||||
this.peekItem = item;
|
||||
this.peekFavorited = item.favorited || false;
|
||||
var frame = document.getElementById('lib-peek-iframe');
|
||||
var loading = document.getElementById('lib-peek-loading');
|
||||
if (!frame) return;
|
||||
if (loading) loading.style.display = '';
|
||||
frame.onload = function () { if (loading) loading.style.display = 'none'; };
|
||||
frame.src = 'about:blank';
|
||||
// `/pages/{id}` sans `?embed=1` : le peek affiche le MÊME éditeur que la
|
||||
// page pleine (sidebar, barre, actions compris), pas une variante.
|
||||
frame.src = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
closePeek() {
|
||||
this.peekItem = null;
|
||||
var frame = document.getElementById('lib-peek-iframe');
|
||||
// On décharge l'éditeur : il resterait en mémoire (et son autosave
|
||||
// tournerait) alors qu'il n'est plus visible.
|
||||
if (frame) { frame.onload = null; frame.src = 'about:blank'; }
|
||||
var loading = document.getElementById('lib-peek-loading');
|
||||
if (loading) { loading.style.display = ''; loading.textContent = 'Chargement…'; }
|
||||
},
|
||||
|
||||
async toggleFavoritePeek() {
|
||||
@@ -1040,40 +1060,16 @@ _renderIcon(item) {
|
||||
}
|
||||
};
|
||||
}
|
||||
// Init peek resize handle
|
||||
// Init peek resize handle — helper partagé fdWirePeekResize (app.js, defer :
|
||||
// câblage différé jusqu'à sa disponibilité, ou immédiat si on est déjà prêt).
|
||||
(function() {
|
||||
var panel = document.querySelector('.peek-overlay');
|
||||
var handle = document.getElementById('lib-peek-resize-handle');
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var deltaX = startX - e.clientX;
|
||||
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', function(e) {
|
||||
if (!dragging) return;
|
||||
document.body.style.userSelect = '';
|
||||
var moved = Math.abs(e.clientX - startX);
|
||||
if (moved < 3) {
|
||||
var el = document.querySelector('.peek-overlay');
|
||||
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* volontaire */ }
|
||||
function wire() {
|
||||
var panel = document.querySelector('.peek-overlay');
|
||||
if (!panel || !window.fdWirePeekResize) return;
|
||||
window.fdWirePeekResize(panel, panel.querySelector('#lib-peek-resize-handle'), function() {
|
||||
if (panel.__x) panel.__x.$data.peekItem = null;
|
||||
});
|
||||
}
|
||||
if (window.fdWirePeekResize || document.readyState !== 'loading') wire();
|
||||
else document.addEventListener('DOMContentLoaded', wire);
|
||||
})();
|
||||
|
||||
@@ -200,39 +200,11 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
},
|
||||
|
||||
_initResizeHandle() {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
var handle = document.getElementById('ws-preview-resize-handle');
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var deltaX = startX - e.clientX;
|
||||
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', function(e) {
|
||||
if (!dragging) return;
|
||||
document.body.style.userSelect = '';
|
||||
var moved = Math.abs(e.clientX - startX);
|
||||
if (moved < 3) {
|
||||
window._wsData.closePreviewPanel();
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* volontaire */ }
|
||||
// Helper partagé (app.js) — pointer capture, persistance fd_peek_width.
|
||||
window.fdWirePeekResize(
|
||||
document.getElementById('ws-preview-panel'),
|
||||
document.getElementById('ws-preview-resize-handle'),
|
||||
function () { window._wsData.closePreviewPanel(); });
|
||||
},
|
||||
|
||||
_recompute() {
|
||||
@@ -403,7 +375,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
return null;
|
||||
},
|
||||
|
||||
// ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ──
|
||||
// ── Preview panel (loads the full-screen editor in an iframe) ──
|
||||
selectForPreview(node) {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
if (!panel) return;
|
||||
@@ -423,9 +395,12 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
window.showToast('Failed to copy', 'error');
|
||||
});
|
||||
};
|
||||
// Load editor in iframe
|
||||
// Load the full-screen editor in the iframe (no `?embed=1`: the peek
|
||||
// shows the same editor as the page itself). `about:blank` first, so
|
||||
// opening another document really reloads the frame instead of keeping
|
||||
// the previous one when the URL happens to be identical.
|
||||
var iframe = document.getElementById('ws-preview-iframe');
|
||||
if (iframe) iframe.src = '/pages/' + node.id + '?embed=1';
|
||||
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id; }
|
||||
// Show panel
|
||||
panel.style.display = 'flex';
|
||||
requestAnimationFrame(function() {
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — My Tasks (tableau de bord façon Notion)
|
||||
*
|
||||
* Trois vues sur les mêmes tâches normalisées par `/my-tasks/api` :
|
||||
* • tableau — liste dense, statut / échéance éditables au clic ;
|
||||
* • kanban — colonnes = options de statut de la base d'origine, glisser
|
||||
* d'une colonne à l'autre écrit le statut ;
|
||||
* • calendrier — mois courant, tâches placées sur leur date d'échéance.
|
||||
*
|
||||
* Tout passe par le mapping enregistré côté serveur
|
||||
* (`collections.task_*_prop`) : le front envoie un champ (`status`, `due`,
|
||||
* `title`), jamais un identifiant de colonne.
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
/* Le tableau de bord est monté par `flowDeckMyTasks()` pour **chaque** noeud
|
||||
* `#my-tasks-app` : la navigation interne de `app.js` (htmx) remplace
|
||||
* `.main-wrapper` sans recharger le document — le <script> est bien rejoué,
|
||||
* mais son garde global faisait sortir le IIFE et la nouvelle coquille restait
|
||||
* vide jusqu'au hard refresh. Le drapeau est désormais porté par l'élément
|
||||
* (`__fdMounted`), pas par `window`.
|
||||
*
|
||||
* Filet supplémentaire : une coquille montée mais **restée vide** est le
|
||||
* symptôme d'un swap interrompu. Plutôt que d'afficher du vide, on remonte
|
||||
* (les delegations d'événements, elles, ne sont posées qu'une fois par
|
||||
* noeud grâce à `__fdWired`). */
|
||||
function flowDeckMyTasks() {
|
||||
'use strict';
|
||||
|
||||
var app = document.getElementById('my-tasks-app');
|
||||
if (!app) return;
|
||||
if (app.__fdMounted && app.innerHTML.trim()) return;
|
||||
app.__fdMounted = true;
|
||||
var wire = !app.__fdWired; // écoutes posées une seule fois par noeud
|
||||
app.__fdWired = true;
|
||||
|
||||
var cfg = {};
|
||||
try { cfg = JSON.parse(app.getAttribute('data-config') || '{}'); } catch (e) { cfg = {}; }
|
||||
|
||||
var state = {
|
||||
view: cfg.view || 'table',
|
||||
due: (cfg.filters && cfg.filters.due) || 'all',
|
||||
sort: (cfg.filters && cfg.filters.sort) || 'due',
|
||||
hide_done: !!(cfg.filters && cfg.filters.hide_done),
|
||||
sources: cfg.sources || [],
|
||||
maxSources: cfg.maxSources || 10,
|
||||
tasks: [],
|
||||
month: null,
|
||||
calView: 'month',
|
||||
calDay: null
|
||||
};
|
||||
|
||||
var MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet',
|
||||
'août', 'septembre', 'octobre', 'novembre', 'décembre'];
|
||||
var DAYS = ['lu', 'ma', 'me', 'je', 've', 'sa', 'di'];
|
||||
|
||||
function csrf() {
|
||||
return (typeof window.getCsrf === 'function') ? window.getCsrf() : '';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// `collection_pages.icon` vaut la chaine sentinelle "file" pour une ligne
|
||||
// sans icône (héritage de l'import, cf. `database_table.js`). L'afficher
|
||||
// telle quel collait le mot "file" devant chaque titre.
|
||||
function rowIcon(icon) {
|
||||
return (!icon || icon === 'file') ? '📄' : icon;
|
||||
}
|
||||
|
||||
// Un identifiant de ligne de base n'est PAS un identifiant de page :
|
||||
// /db/pages/{id}/open/api renvoie la page de contenu (créée à la volée)
|
||||
// qui porte les blocs de la tâche. On ne navigue JAMAIS vers
|
||||
// /pages/{rowId} : cet id désigne une page sans rapport (cf.
|
||||
// database_table.js) — d'où le panneau latéral, comme dans Library.
|
||||
var PEEK_SVG_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" ' +
|
||||
'x2="18" y2="18"/></svg>';
|
||||
var PEEK_SVG_FULL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>' +
|
||||
'<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
|
||||
// Le panneau vit HORS de `#my-tasks-app` : `render()` remplace le contenu
|
||||
// de l'app à chaque filtre, le panneau doit survivre.
|
||||
function ensurePeek() {
|
||||
var p = document.getElementById('mt-peek');
|
||||
if (p) return p;
|
||||
p = document.createElement('div');
|
||||
p.className = 'mt-peek';
|
||||
p.id = 'mt-peek';
|
||||
p.setAttribute('aria-hidden', 'true');
|
||||
p.innerHTML = '<div class="mt-peek-handle" id="mt-peek-handle"></div>' +
|
||||
'<div class="mt-peek-head">' +
|
||||
'<button class="mt-peek-btn" id="mt-peek-close" title="Close" ' +
|
||||
'aria-label="Fermer">' + PEEK_SVG_CLOSE + '</button>' +
|
||||
'<div class="mt-peek-title" id="mt-peek-title"></div>' +
|
||||
'<button class="mt-peek-btn" id="mt-peek-full" title="Open full page" ' +
|
||||
'aria-label="Ouvrir dans une page entière">' + PEEK_SVG_FULL + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="mt-peek-body" id="mt-peek-body">' +
|
||||
'<div class="mt-peek-loading" id="mt-peek-loading">Chargement…</div>' +
|
||||
'<iframe id="mt-peek-iframe" title="Contenu de la tâche" ' +
|
||||
'style="display:none"></iframe></div>';
|
||||
document.body.appendChild(p);
|
||||
|
||||
document.getElementById('mt-peek-close').addEventListener('click', closePeek);
|
||||
document.getElementById('mt-peek-full').addEventListener('click', function () {
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
|
||||
});
|
||||
wirePeekResize(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function closePeek() {
|
||||
var p = document.getElementById('mt-peek');
|
||||
if (!p) return;
|
||||
p.classList.remove('open');
|
||||
p.setAttribute('aria-hidden', 'true');
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
// On décharge l'iframe : elle garde sinon en mémoire tout l'éditeur
|
||||
// d'un document que l'on vient de quitter.
|
||||
if (f) { f.src = ''; delete f.dataset.page; f.style.display = 'none'; }
|
||||
var l = document.getElementById('mt-peek-loading');
|
||||
if (l) l.style.display = '';
|
||||
}
|
||||
|
||||
function wirePeekResize(panel) {
|
||||
// Helper partagé (app.js) : pointer capture, borne 300–90vw, clic =
|
||||
// fermer, largeur persistée (localStorage fd_peek_width) — comme Library.
|
||||
window.fdWirePeekResize(panel, document.getElementById('mt-peek-handle'), closePeek);
|
||||
}
|
||||
|
||||
function openRowContent(rowId, title) {
|
||||
var panel = ensurePeek();
|
||||
document.getElementById('mt-peek-title').textContent = title || '';
|
||||
var loading = document.getElementById('mt-peek-loading');
|
||||
var frame = document.getElementById('mt-peek-iframe');
|
||||
if (loading) loading.style.display = '';
|
||||
if (frame) { frame.style.display = 'none'; frame.src = ''; delete frame.dataset.page; }
|
||||
panel.classList.add('open');
|
||||
panel.setAttribute('aria-hidden', 'false');
|
||||
|
||||
api('/db/pages/' + rowId + '/open/api').then(function (d) {
|
||||
var pid = d && d.page_id;
|
||||
if (!pid) throw new Error('page de contenu introuvable');
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
if (!f || !document.getElementById('mt-peek').classList.contains('open')) return;
|
||||
if (loading) loading.style.display = 'none';
|
||||
f.dataset.page = pid;
|
||||
f.style.display = '';
|
||||
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
|
||||
// l'éditeur, sinon l'URL identique laisse le document précédent
|
||||
// affiché. `/pages/{id}` sans `?embed=1` = l'éditeur plein écran.
|
||||
f.src = 'about:blank';
|
||||
f.src = '/pages/' + pid;
|
||||
}).catch(function (e) {
|
||||
if (loading) {
|
||||
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
|
||||
loading.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncUrl() {
|
||||
// `replaceState` peut être refusé (iframe sandbox, `file://`,
|
||||
// navigation cross-origin) : l'URL n'est qu'un confort, jamais
|
||||
// l'interface — on ne laisse jamais une exception casser l'action.
|
||||
try {
|
||||
history.replaceState(null, '', '/my-tasks?' + qs());
|
||||
} catch (e) { /* ignoré */ }
|
||||
}
|
||||
|
||||
function qs() {
|
||||
var p = new URLSearchParams();
|
||||
p.set('view', state.view);
|
||||
p.set('due', state.due);
|
||||
p.set('sort', state.sort);
|
||||
if (state.hide_done) p.set('hide_done', 'true');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = { 'X-CSRF-Token': csrf() };
|
||||
if (opts.body) headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status));
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Données ───────────────────────────────────────────────────── */
|
||||
|
||||
function load() {
|
||||
return api('/my-tasks/api?' + qs()).then(function (d) {
|
||||
state.tasks = d.tasks || [];
|
||||
state.sources = d.sources || [];
|
||||
render();
|
||||
}).catch(function (e) {
|
||||
app.innerHTML = '<div class="my-tasks-error">' + esc(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function configuredSources() {
|
||||
return state.sources.filter(function (s) { return s.configured; });
|
||||
}
|
||||
|
||||
/* ── Barre d'outils ────────────────────────────────────────────── */
|
||||
|
||||
function toolbar() {
|
||||
var h = '<div class="mt-toolbar">';
|
||||
h += '<div class="mt-toolbar-left">';
|
||||
h += viewTabs();
|
||||
h += '</div><div class="mt-toolbar-right">';
|
||||
h += select('mt-due-filter', 'due', [
|
||||
['all', 'Toutes les échéances'],
|
||||
['today', 'Échéance aujourd\'hui'],
|
||||
['overdue', 'En retard + aujourd\'hui'],
|
||||
['week', 'Cette semaine']
|
||||
]);
|
||||
h += select('mt-sort', 'sort', [
|
||||
['due', 'Tri : échéance'],
|
||||
['status', 'Tri : statut'],
|
||||
['source', 'Tri : source'],
|
||||
['created', 'Tri : création']
|
||||
]);
|
||||
h += '<label class="mt-toggle"><input type="checkbox" id="mt-hide-done"' +
|
||||
(state.hide_done ? ' checked' : '') + '> Masquer terminées</label>';
|
||||
h += '<button class="mt-new-btn" id="mt-new-btn">+ Nouvelle tâche</button>';
|
||||
h += '</div></div>';
|
||||
h += sourcesBar();
|
||||
return h;
|
||||
}
|
||||
|
||||
function viewTabs() {
|
||||
var labels = { table: 'Tableau', board: 'Kanban', calendar: 'Calendrier' };
|
||||
var h = '<div class="mt-view-tabs">';
|
||||
Object.keys(labels).forEach(function (k) {
|
||||
// `data-no-nav` : le changement de vue est traité ici, en JS.
|
||||
// Sans lui, l'intercepteur de liens de `app.js` rejoue un
|
||||
// chargement complet de la page et remplace le DOM rendu.
|
||||
// Le `href` reste présent : le lien reste partageable et
|
||||
// ouvrable dans un onglet.
|
||||
h += '<a class="mt-view-tab' + (state.view === k ? ' active' : '') +
|
||||
'" data-no-nav href="/my-tasks?' + withView(k) + '">' +
|
||||
labels[k] + '</a>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function withView(v) {
|
||||
var p = new URLSearchParams();
|
||||
p.set('view', v);
|
||||
p.set('due', state.due);
|
||||
p.set('sort', state.sort);
|
||||
if (state.hide_done) p.set('hide_done', 'true');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function select(cls, key, opts) {
|
||||
var s = '<select class="mt-select ' + cls + '" data-key="' + key + '">';
|
||||
opts.forEach(function (o) {
|
||||
s += '<option value="' + esc(o[0]) + '"' +
|
||||
(state[key] === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
|
||||
});
|
||||
return s + '</select>';
|
||||
}
|
||||
|
||||
function sourcesBar() {
|
||||
var n = state.sources.length;
|
||||
var ok = configuredSources().length;
|
||||
var pend = state.sources.filter(function (s) { return !s.configured; });
|
||||
var h = '<div class="mt-sources">';
|
||||
h += '<span class="mt-sources-label">Sources : <b>' + ok + '/' + state.maxSources +
|
||||
'</b> base(s) connectée(s)</span>';
|
||||
state.sources.forEach(function (s) {
|
||||
h += '<a class="mt-source-chip' + (s.configured ? '' : ' pending') +
|
||||
'" href="/db/' + s.id + '" title="' + esc(s.workspace_name) + '">' +
|
||||
esc(s.icon || '📋') + ' ' + esc(s.name) + '</a>';
|
||||
// Une base connectée au mapping incomplet se corrige d'ici.
|
||||
if (!s.configured) {
|
||||
h += '<button class="mt-source-fix" data-fix-source="' + s.id +
|
||||
'" title="Relier les colonnes manquantes : ' +
|
||||
esc((s.missing_roles || []).join(', ')) + '">Configurer</button>';
|
||||
}
|
||||
});
|
||||
if (pend.length) {
|
||||
h += '<span class="mt-sources-warn" title="Ces bases n’émettent pas de tâche ' +
|
||||
'tant que les 3 colonnes ne sont pas liées">⚠ ' + pend.length +
|
||||
' base(s) à configurer</span>';
|
||||
}
|
||||
if (n >= state.maxSources) {
|
||||
h += '<span class="mt-sources-warn">Limite de ' + state.maxSources +
|
||||
' bases atteinte</span>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── Vue tableau ──────────────────────────────────────────────────────
|
||||
* Lignes en `div`, comme Library : c'est ce qui permet de n'afficher le
|
||||
* bouton « Open » qu'au survol de la ligne (`.mt-hover-only`).
|
||||
*/
|
||||
|
||||
// Bouton « Open » : SVG et libellé identiques à `.btn-open` de Library,
|
||||
// pour que les deux listes se ressemblent exactement.
|
||||
function openButton(t) {
|
||||
return '<button class="mt-open-btn mt-hover-only" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" ' +
|
||||
'title="Open in side peek" aria-label="Ouvrir dans le panneau latéral">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/>' +
|
||||
'<line x1="15" y1="3" x2="15" y2="21"/></svg> Open</button>';
|
||||
}
|
||||
|
||||
function viewTable() {
|
||||
if (!state.tasks.length) return emptyState();
|
||||
var h = '<div class="mt-table">' +
|
||||
'<div class="mt-thead">' +
|
||||
'<div class="mt-th mt-cell-name">Tâche</div>' +
|
||||
'<div class="mt-th mt-cell-status">Statut</div>' +
|
||||
'<div class="mt-th mt-cell-due">Échéance</div>' +
|
||||
'<div class="mt-th mt-cell-src">Source</div>' +
|
||||
'</div>';
|
||||
state.tasks.forEach(function (t) {
|
||||
h += '<div class="mt-row" data-id="' + t.id + '">';
|
||||
h += '<div class="mt-cell mt-cell-name">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-title-text" data-edit="title" title="Cliquer pour renommer">' +
|
||||
esc(t.title) + '</span>' + openButton(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-status">' + statusCell(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-due">' + dueCell(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-src"><a class="mt-chip" href="/db/' +
|
||||
t.collection_id + '" title="' + esc(t.workspace_name) +
|
||||
' · ouvrir la base">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</a></div>';
|
||||
h += '</div>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function statusCell(t) {
|
||||
if (!t.status_options || !t.status_options.length) {
|
||||
return '<span class="mt-static">' + esc(t.status || '—') + '</span>';
|
||||
}
|
||||
return '<select class="mt-status-select" data-id="' + t.id +
|
||||
'" data-field="status">' + t.status_options.map(function (o) {
|
||||
return '<option value="' + esc(o) + '"' +
|
||||
(o === t.status ? ' selected' : '') + '>' + esc(o) + '</option>';
|
||||
}).join('') + '</select>';
|
||||
}
|
||||
|
||||
function dueCell(t) {
|
||||
var cls = t.due_state ? ' mt-due-' + t.due_state : '';
|
||||
return '<input type="date" class="mt-due-input' + cls + '" data-id="' + t.id +
|
||||
'" data-field="due" value="' + esc(t.due || '') + '">';
|
||||
}
|
||||
|
||||
/* ── Vue kanban ────────────────────────────────────────────────── */
|
||||
|
||||
function viewBoard() {
|
||||
var cols = [], seen = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
(t.status_options || []).forEach(function (o) {
|
||||
if (!seen[o]) { seen[o] = 1; cols.push(o); }
|
||||
});
|
||||
if (!t.status || seen[t.status] === undefined) {
|
||||
if (t.status && !seen[t.status]) { seen[t.status] = 1; cols.push(t.status); }
|
||||
}
|
||||
});
|
||||
if (!cols.length) cols = ['À faire'];
|
||||
|
||||
var h = '<div class="mt-board">';
|
||||
cols.forEach(function (col) {
|
||||
var items = state.tasks.filter(function (t) { return (t.status || '') === col; });
|
||||
h += '<div class="mt-col" data-status="' + esc(col) + '">';
|
||||
h += '<div class="mt-col-head">' + esc(col) + ' <span class="mt-count">' +
|
||||
items.length + '</span></div>';
|
||||
h += '<div class="mt-col-body">';
|
||||
items.forEach(function (t) {
|
||||
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
|
||||
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
|
||||
esc(t.due) + '</span>' : '') +
|
||||
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</span></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── Vue calendrier (5 vues) ─────────────────────────────────────── */
|
||||
|
||||
var CAL_VIEWS = [
|
||||
['month', 'Mois'], ['year', 'Année'], ['week', 'Semaine'],
|
||||
['day', 'Jour'], ['planning', 'Planning']
|
||||
];
|
||||
var OPEN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/>' +
|
||||
'<line x1="15" y1="3" x2="15" y2="21"/></svg>';
|
||||
|
||||
function calEnsure() {
|
||||
if (!state.month) {
|
||||
var n = new Date();
|
||||
state.month = { y: n.getFullYear(), m: n.getMonth() };
|
||||
}
|
||||
if (!state.calDay) {
|
||||
var t = new Date();
|
||||
state.calDay = t.getFullYear() + '-' +
|
||||
('0' + (t.getMonth() + 1)).slice(-2) + '-' +
|
||||
('0' + t.getDate()).slice(-2);
|
||||
}
|
||||
}
|
||||
|
||||
function isoDate(d) {
|
||||
return d.getFullYear() + '-' +
|
||||
('0' + (d.getMonth() + 1)).slice(-2) + '-' +
|
||||
('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
|
||||
function calDate(iso) {
|
||||
var p = String(iso || '').split('-');
|
||||
return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
||||
}
|
||||
|
||||
// Tâches groupées par jour d'échéance (celles sans échéance sont ignorées).
|
||||
function tasksByDay() {
|
||||
var byDay = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
if (!t.due) return;
|
||||
(byDay[t.due] = byDay[t.due] || []).push(t);
|
||||
});
|
||||
return byDay;
|
||||
}
|
||||
|
||||
// Décalage du calendrier, la granularité dépendant de la vue courante.
|
||||
function calShift(delta) {
|
||||
calEnsure();
|
||||
var v = state.calView;
|
||||
if (v === 'year') {
|
||||
state.month = { y: state.month.y + delta, m: state.month.m };
|
||||
} else if (v === 'week') {
|
||||
var w = calDate(state.calDay);
|
||||
w.setDate(w.getDate() + delta * 7);
|
||||
state.calDay = isoDate(w);
|
||||
} else if (v === 'day') {
|
||||
var d = calDate(state.calDay);
|
||||
d.setDate(d.getDate() + delta);
|
||||
state.calDay = isoDate(d);
|
||||
state.month = { y: d.getFullYear(), m: d.getMonth() };
|
||||
} else {
|
||||
var m = state.month.m + delta;
|
||||
state.month = {
|
||||
m: ((m % 12) + 12) % 12,
|
||||
y: state.month.y + Math.floor(m / 12)
|
||||
};
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function calTitle() {
|
||||
calEnsure();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
if (state.calView === 'year') return String(y);
|
||||
if (state.calView === 'planning') return 'Planning — ' + MONTHS[m] + ' ' + y;
|
||||
if (state.calView === 'day') {
|
||||
var d = calDate(state.calDay);
|
||||
return DAYS[(d.getDay() + 6) % 7] + ' ' + d.getDate() + ' ' +
|
||||
MONTHS[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
if (state.calView === 'week') {
|
||||
var s = calDate(state.calDay);
|
||||
var lead = (s.getDay() + 6) % 7; // lundi = 0
|
||||
s.setDate(s.getDate() - lead);
|
||||
var e = new Date(s.getFullYear(), s.getMonth(), s.getDate() + 6);
|
||||
return s.getDate() + ' – ' + e.getDate() + ' ' + MONTHS[e.getMonth()] +
|
||||
' ' + e.getFullYear();
|
||||
}
|
||||
return MONTHS[m] + ' ' + y;
|
||||
}
|
||||
|
||||
function calTabs() {
|
||||
var h = '<div class="mt-cal-tabs">';
|
||||
CAL_VIEWS.forEach(function (v) {
|
||||
h += '<button class="mt-cal-tab' + (state.calView === v[0] ? ' active' : '') +
|
||||
'" data-cal-view="' + v[0] + '">' + v[1] + '</button>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
// Événement de calendrier : titre + bouton d'ouverture au survol.
|
||||
function calEvHtml(t, draggable) {
|
||||
return '<div class="mt-cal-ev' + (draggable ? ' drag' : '') + '"' +
|
||||
(draggable ? ' draggable="true" data-id="' + t.id + '"' : '') +
|
||||
' title="' + esc(t.collection_name) + ' · ' + esc(t.status || '') +
|
||||
' · ' + esc(t.due || '') + '">' +
|
||||
'<span class="mt-cal-ev-text">' + esc(t.title) + '</span>' +
|
||||
'<button class="mt-cal-open mt-hover-only" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
|
||||
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function viewCalendar() {
|
||||
calEnsure();
|
||||
var h = '<div class="mt-cal">' +
|
||||
'<div class="mt-cal-head">' +
|
||||
'<button class="mt-cal-nav" data-nav="-1" aria-label="Précédent">‹</button>' +
|
||||
'<span class="mt-cal-title">' + esc(calTitle()) + '</span>' +
|
||||
'<button class="mt-cal-nav" data-nav="1" aria-label="Suivant">›</button>' +
|
||||
'</div>' + calTabs() + '<div class="mt-cal-body">';
|
||||
if (state.calView === 'year') h += calYear();
|
||||
else if (state.calView === 'week') h += calWeek();
|
||||
else if (state.calView === 'day') h += calDay();
|
||||
else if (state.calView === 'planning') h += calPlanning();
|
||||
else h += calMonth();
|
||||
return h + '</div></div>';
|
||||
}
|
||||
|
||||
/* Mois : grille 7 × 6, glisser-déposer pour changer l'échéance. */
|
||||
function calMonth() {
|
||||
calEnsure();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
var byDay = tasksByDay();
|
||||
var first = new Date(y, m, 1);
|
||||
var lead = (first.getDay() + 6) % 7; // lundi = 0
|
||||
var start = new Date(y, m, 1 - lead);
|
||||
var todayIso = isoDate(new Date());
|
||||
|
||||
var h = '<div class="mt-cal-grid">';
|
||||
DAYS.forEach(function (d) { h += '<div class="mt-cal-dow">' + d + '</div>'; });
|
||||
for (var i = 0; i < 42; i++) {
|
||||
var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
var key = isoDate(d);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-cell' + (d.getMonth() !== m ? ' out' : '') +
|
||||
(key === todayIso ? ' today' : '') + '" data-day="' + key + '">';
|
||||
h += '<div class="mt-cal-day">' + d.getDate() + '</div>';
|
||||
evs.forEach(function (t) { h += calEvHtml(t, true); });
|
||||
h += '</div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Année : 12 mini-mois ; un point signale un jour chargé. */
|
||||
function calYear() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var todayIso = isoDate(new Date());
|
||||
var h = '<div class="mt-cal-year">';
|
||||
for (var mo = 0; mo < 12; mo++) {
|
||||
var y = state.month.y;
|
||||
var first = new Date(y, mo, 1);
|
||||
var lead = (first.getDay() + 6) % 7;
|
||||
var start = new Date(y, mo, 1 - lead);
|
||||
var dim = new Date(y, mo + 1, 0).getDate();
|
||||
h += '<div class="mt-cal-mini">';
|
||||
h += '<div class="mt-cal-mini-title">' + MONTHS[mo] + '</div>';
|
||||
h += '<div class="mt-cal-mini-grid">';
|
||||
DAYS.forEach(function (d) {
|
||||
h += '<span class="mt-cal-mini-dow">' + d.charAt(0) + '</span>';
|
||||
});
|
||||
for (var i = 0; i < lead; i++) h += '<span class="mt-cal-mini-day out"></span>';
|
||||
for (var dd = 1; dd <= dim; dd++) {
|
||||
var key = y + '-' + ('0' + (mo + 1)).slice(-2) + '-' +
|
||||
('0' + dd).slice(-2);
|
||||
var n = (byDay[key] || []).length;
|
||||
h += '<button class="mt-cal-mini-day' + (key === todayIso ? ' today' : '') +
|
||||
(n ? ' busy' : '') + '" data-cal-day="' + key + '" ' +
|
||||
'title="' + dd + ' ' + MONTHS[mo] + (n ? ' — ' + n + ' tâche(s)' : '') + '">' +
|
||||
dd + '</button>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Semaine : 7 colonnes, une par jour. */
|
||||
function calWeek() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var s = calDate(state.calDay);
|
||||
var lead = (s.getDay() + 6) % 7; // lundi = 0
|
||||
s.setDate(s.getDate() - lead);
|
||||
var todayIso = isoDate(new Date());
|
||||
var h = '<div class="mt-cal-week">';
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i);
|
||||
var key = isoDate(d);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-wcol' + (key === todayIso ? ' today' : '') + '">';
|
||||
h += '<div class="mt-cal-wcol-head">' + DAYS[i] + ' ' + d.getDate() + '</div>';
|
||||
h += '<div class="mt-cal-wcol-body">';
|
||||
if (!evs.length) h += '<div class="mt-cal-wempty">—</div>';
|
||||
evs.forEach(function (t) { h += calEvHtml(t, false); });
|
||||
h += '</div></div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Jour : la liste complète des tâches du jour sélectionné. */
|
||||
function calDay() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var key = state.calDay;
|
||||
var evs = byDay[key] || [];
|
||||
var h = '<div class="mt-cal-dayview">';
|
||||
h += '<div class="mt-cal-dayview-head">' + esc(calTitle()) +
|
||||
' · <b>' + evs.length + '</b> tâche(s)</div>';
|
||||
if (!evs.length) {
|
||||
h += '<div class="mt-cal-dayview-empty">Aucune tâche pour ce jour.</div>';
|
||||
return h + '</div>';
|
||||
}
|
||||
h += '<div class="mt-cal-dayview-list">';
|
||||
evs.forEach(function (t) {
|
||||
h += '<div class="mt-cal-task" data-id="' + t.id + '">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-cal-task-title">' + esc(t.title) + '</span>' +
|
||||
'<span class="mt-cal-task-src">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</span>' +
|
||||
'<span class="mt-cal-task-status">' + esc(t.status || '—') + '</span>' +
|
||||
'<button class="mt-open-btn" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
|
||||
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + ' Open</button>' +
|
||||
'</div>';
|
||||
});
|
||||
return h + '</div></div>';
|
||||
}
|
||||
|
||||
/* Planning : colonnes = statuts, lignes = jours du mois (tâches placées
|
||||
à leur date d'échéance). C'est la vue « planning » de Notion. */
|
||||
function calPlanning() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
var statuses = [], seen = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
if (!t.due) return;
|
||||
var s = t.status || 'Sans statut';
|
||||
if (!seen[s]) { seen[s] = 1; statuses.push(s); }
|
||||
});
|
||||
if (!statuses.length) statuses = ['Sans statut'];
|
||||
var dim = new Date(y, m + 1, 0).getDate();
|
||||
var todayIso = isoDate(new Date());
|
||||
|
||||
var h = '<div class="mt-cal-plan">';
|
||||
h += '<div class="mt-cal-plan-row head"><div class="mt-cal-plan-gutter"></div>';
|
||||
statuses.forEach(function (s) {
|
||||
h += '<div class="mt-cal-plan-col-title">' + esc(s) + '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
for (var d = 1; d <= dim; d++) {
|
||||
var key = y + '-' + ('0' + (m + 1)).slice(-2) + '-' + ('0' + d).slice(-2);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-plan-row' + (key === todayIso ? ' today' : '') + '">' +
|
||||
'<div class="mt-cal-plan-gutter">' + d + '</div>';
|
||||
statuses.forEach(function (s) {
|
||||
h += '<div class="mt-cal-plan-cell">';
|
||||
evs.filter(function (t) {
|
||||
return (t.status || 'Sans statut') === s;
|
||||
}).forEach(function (t) { h += calEvHtml(t, false); });
|
||||
h += '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── États vides ───────────────────────────────────────────────── */
|
||||
|
||||
function emptyState() {
|
||||
var title, msg, hint, act;
|
||||
var connected = configuredSources().length;
|
||||
if (!state.sources.length) {
|
||||
title = 'Aucune base connectée';
|
||||
msg = "Aucune base de tâches n'alimente ce tableau de bord.";
|
||||
hint = 'Ouvrez une base, puis cliquez sur « Convertir en base de tâches » ' +
|
||||
'dans sa barre (à gauche du bouton New) et reliez les colonnes ' +
|
||||
'Assigné à, Statut et Échéance.';
|
||||
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
|
||||
} else if (!connected) {
|
||||
title = 'Mapping incomplet';
|
||||
msg = 'Bases connectées mais aucune configurée.';
|
||||
hint = 'Il manque le mapping Assigné à / Statut / Échéance. Ouvrez la ' +
|
||||
'base et cliquez sur « Configurer » dans sa barre.';
|
||||
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
|
||||
} else if (state.due !== 'all' || state.hide_done || state.sort !== 'due') {
|
||||
title = 'Aucun résultat';
|
||||
msg = 'Aucune tâche ne correspond aux filtres actifs.';
|
||||
hint = '';
|
||||
act = '<a class="mt-empty-btn" href="/my-tasks">Réinitialiser les filtres</a>';
|
||||
} else {
|
||||
title = 'Rien à faire';
|
||||
msg = "Aucune tâche ne vous est assignée pour le moment.";
|
||||
hint = 'Ajoutez-en une, ou branchesz une autre base pour voir plus large.';
|
||||
act = '<button class="mt-empty-btn" id="mt-new-btn">Créer une tâche</button>';
|
||||
}
|
||||
// Même gabarit centré que l'état vide rendu par le serveur
|
||||
// (`_render_empty`), pour que les deux se ressemblent.
|
||||
return '<div class="mt-empty"><div class="mt-empty-icon">📋</div>' +
|
||||
'<h2>' + esc(title) + '</h2><p>' + esc(msg) + '</p>' +
|
||||
(hint ? '<p class="mt-empty-hint">' + esc(hint) + '</p>' : '') +
|
||||
act + '</div>';
|
||||
}
|
||||
|
||||
/* ── Modale « nouvelle tâche » (multi-destinations) ────────────── */
|
||||
|
||||
// Ouvre la modale de conversion pour une base donnée (les bases
|
||||
// connectées mais mal configurées ne peuvent pas recevoir de tâche).
|
||||
function openConvertModal(collectionId) {
|
||||
if (collectionId && window.FlowDeckTaskDb) {
|
||||
window.FlowDeckTaskDb.open(Number(collectionId));
|
||||
}
|
||||
}
|
||||
|
||||
function openNewModal() {
|
||||
var srcs = configuredSources();
|
||||
if (!srcs.length) {
|
||||
alert("Aucune base de tâches connectée.\n\nConvertissez d'abord une base : " +
|
||||
"ouvrez-la, puis cliquez sur « Convertir en base de tâches » " +
|
||||
"dans la barre de la base, à gauche du bouton New.");
|
||||
return;
|
||||
}
|
||||
var groups = {};
|
||||
srcs.forEach(function (s) {
|
||||
(groups[s.workspace_name || 'Autres'] = groups[s.workspace_name || 'Autres'] || [])
|
||||
.push(s);
|
||||
});
|
||||
var h = '<div class="mt-modal-overlay" id="mt-modal-overlay"><div class="mt-modal">';
|
||||
h += '<div class="mt-modal-head"><span>Nouvelle tâche</span>' +
|
||||
'<button class="mt-modal-close" id="mt-modal-close">✕</button></div>';
|
||||
h += '<div class="mt-modal-body">';
|
||||
h += '<label class="mt-field">Titre<input id="mt-new-title" type="text" ' +
|
||||
'placeholder="Ex. Envoyer la maquette"></label>';
|
||||
h += '<label class="mt-field">Échéance<input id="mt-new-due" type="date"></label>';
|
||||
Object.keys(groups).forEach(function (ws) {
|
||||
h += '<div class="mt-modal-group">' + esc(ws) + '</div>';
|
||||
groups[ws].forEach(function (s) {
|
||||
h += '<label class="mt-dest"><input type="radio" name="mt-dest" value="' +
|
||||
s.id + '"><span class="mt-icon">' + esc(s.icon || '📋') + '</span>' +
|
||||
esc(s.name) + '</label>';
|
||||
});
|
||||
});
|
||||
h += '</div><div class="mt-modal-foot">' +
|
||||
'<button class="mt-btn-cancel" id="mt-new-cancel">Annuler</button>' +
|
||||
'<button class="mt-btn-primary" id="mt-new-create">Créer</button></div>';
|
||||
h += '</div></div>';
|
||||
document.body.insertAdjacentHTML('beforeend', h);
|
||||
|
||||
var first = document.querySelector('input[name="mt-dest"]');
|
||||
if (first) first.checked = true;
|
||||
document.getElementById('mt-new-title').focus();
|
||||
|
||||
var close = function () {
|
||||
var ov = document.getElementById('mt-modal-overlay');
|
||||
if (ov) ov.remove();
|
||||
};
|
||||
// `Escape` ferme aussi la modale.
|
||||
document.addEventListener('keydown', function onEsc(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
close();
|
||||
});
|
||||
document.getElementById('mt-modal-close').onclick = close;
|
||||
document.getElementById('mt-new-cancel').onclick = close;
|
||||
document.getElementById('mt-modal-overlay').onclick = function (e) {
|
||||
if (e.target === this) close();
|
||||
};
|
||||
document.getElementById('mt-new-create').onclick = function () {
|
||||
var title = document.getElementById('mt-new-title').value.trim();
|
||||
var due = document.getElementById('mt-new-due').value;
|
||||
var dest = document.querySelector('input[name="mt-dest"]:checked');
|
||||
if (!title) { alert('Donnez un titre à la tâche.'); return; }
|
||||
if (!dest) { alert('Choisissez une base de destination.'); return; }
|
||||
api('/my-tasks/api/task', {
|
||||
method: 'POST',
|
||||
body: { collection_id: Number(dest.value), title: title, due: due || null }
|
||||
}).then(function () { close(); load(); })
|
||||
.catch(function (e) { close(); alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Écriture en direct ────────────────────────────────────────── */
|
||||
|
||||
function patch(id, field, value) {
|
||||
return api('/my-tasks/api/task/' + id, { method: 'PUT', body: { field: field, value: value } })
|
||||
.then(function (d) {
|
||||
if (d.left_mytasks) {
|
||||
state.tasks = state.tasks.filter(function (t) { return t.id !== id; });
|
||||
render();
|
||||
} else if (d.task) {
|
||||
state.tasks = state.tasks.map(function (t) {
|
||||
return t.id === d.task.id ? d.task : t;
|
||||
});
|
||||
render();
|
||||
}
|
||||
})
|
||||
.catch(function (e) { alert(e.message); load(); });
|
||||
}
|
||||
|
||||
function startTitleEdit(span) {
|
||||
if (span.querySelector('input')) return;
|
||||
var tr = span.closest('.mt-row');
|
||||
var id = Number(tr.dataset.id);
|
||||
var old = span.textContent;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'mt-title-input';
|
||||
input.value = old;
|
||||
span.textContent = '';
|
||||
span.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
var done = false;
|
||||
var commit = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var v = input.value.trim();
|
||||
if (v && v !== old) patch(id, 'title', v); else render();
|
||||
};
|
||||
input.onblur = commit;
|
||||
input.onkeydown = function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
if (e.key === 'Escape') { done = true; render(); }
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Rendu + délégation d'événements ───────────────────────────── */
|
||||
|
||||
function render() {
|
||||
var body = state.view === 'board' ? viewBoard()
|
||||
: state.view === 'calendar' ? viewCalendar()
|
||||
: viewTable();
|
||||
app.innerHTML = '<div class="mt-head"><h1>📋 My Tasks</h1>' +
|
||||
'<span class="mt-count-total">' + state.tasks.length + ' tâche(s)</span></div>' +
|
||||
toolbar() + body;
|
||||
wireDnD();
|
||||
}
|
||||
|
||||
function wireDnD() {
|
||||
var cards = app.querySelectorAll('[draggable="true"]');
|
||||
cards.forEach(function (c) {
|
||||
c.addEventListener('dragstart', function (e) {
|
||||
e.dataTransfer.setData('text/plain', c.dataset.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
});
|
||||
app.querySelectorAll('.mt-col').forEach(function (col) {
|
||||
col.addEventListener('dragover', function (e) { e.preventDefault(); });
|
||||
col.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
var id = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (id && col.dataset.status) patch(id, 'status', col.dataset.status);
|
||||
});
|
||||
});
|
||||
// Calendrier : glisser une tâche sur un jour change son échéance.
|
||||
app.querySelectorAll('.mt-cal-cell').forEach(function (cell) {
|
||||
cell.addEventListener('dragover', function (e) { e.preventDefault(); });
|
||||
cell.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
var id = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (id && cell.dataset.day) patch(id, 'due', cell.dataset.day);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (wire) app.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
|
||||
if (t.id === 'mt-new-btn') { openNewModal(); return; }
|
||||
if (t.dataset && t.dataset.openRow) {
|
||||
e.preventDefault();
|
||||
// Le titre vient de l'attribut : il est disponible aussi bien dans
|
||||
// le tableau que dans le calendrier ou la vue Jour.
|
||||
var label = t.dataset.openTitle;
|
||||
if (!label) {
|
||||
var row = t.closest('.mt-row');
|
||||
var cell = row && row.querySelector('.mt-title-text');
|
||||
label = cell ? cell.textContent : '';
|
||||
}
|
||||
openRowContent(Number(t.dataset.openRow), label);
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.fixSource) {
|
||||
e.preventDefault();
|
||||
openConvertModal(t.dataset.fixSource);
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains('mt-cal-nav')) {
|
||||
calShift(Number(t.dataset.nav));
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.calView) {
|
||||
state.calView = t.dataset.calView;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.calDay) {
|
||||
// Un jour de la vue Année ouvre la vue Jour dessus.
|
||||
state.calDay = t.dataset.calDay;
|
||||
var cd = calDate(state.calDay);
|
||||
state.month = { y: cd.getFullYear(), m: cd.getMonth() };
|
||||
state.calView = 'day';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.edit === 'title') { startTitleEdit(t); return; }
|
||||
|
||||
var link = t.closest ? t.closest('a.mt-view-tab') : null;
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
var p = new URLSearchParams(link.getAttribute('href').split('?')[1] || '');
|
||||
state.view = p.get('view') || 'table';
|
||||
syncUrl();
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
if (wire) app.addEventListener('change', function (e) {
|
||||
var t = e.target;
|
||||
if (t.classList.contains('mt-select')) {
|
||||
state[t.dataset.key] = t.value;
|
||||
syncUrl();
|
||||
load();
|
||||
} else if (t.id === 'mt-hide-done') {
|
||||
state.hide_done = t.checked;
|
||||
syncUrl();
|
||||
load();
|
||||
} else if (t.classList.contains('mt-status-select')) {
|
||||
patch(Number(t.dataset.id), 'status', t.value);
|
||||
} else if (t.classList.contains('mt-due-input')) {
|
||||
patch(Number(t.dataset.id), 'due', t.value);
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
|
||||
/* Une modale ou un panneau restés ouverts pendant une navigation
|
||||
* masqueraient la page suivante : on referme au remontage. */
|
||||
if (wire) {
|
||||
var staleModal = document.getElementById('mt-modal-overlay');
|
||||
if (staleModal) staleModal.remove();
|
||||
closePeek();
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// `Escape` ferme le panneau latéral, comme dans Library.
|
||||
if (e.key === 'Escape') closePeek();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// htmx ré-exécute les <script> de la zone swappée : on remonte à chaque
|
||||
// navigation interne. `popstate` couvre le bouton « retour », où le <script>
|
||||
// n'est pas forcément rejoué.
|
||||
document.addEventListener('htmx:afterSwap', flowDeckMyTasks);
|
||||
window.addEventListener('popstate', flowDeckMyTasks);
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flowDeckMyTasks);
|
||||
} else {
|
||||
flowDeckMyTasks();
|
||||
}
|
||||
@@ -2104,9 +2104,9 @@ applyAIBlocks(text){
|
||||
async _pickSyncedBlock(idx){
|
||||
const block=this.blocks[idx];if(!block)return;
|
||||
try{
|
||||
const r=await fetch('/api/synced-blocks');const d=await r.json();
|
||||
const r=await fetch('/board/api/synced-blocks');const d=await r.json();
|
||||
const items=d.synced_blocks||[];
|
||||
if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;}
|
||||
if(!items.length){this.showToast('No synced blocks yet — create one from a Board page first.');return;}
|
||||
const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';});
|
||||
const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n'));
|
||||
if(!choice)return;
|
||||
@@ -2114,7 +2114,7 @@ applyAIBlocks(text){
|
||||
if(!match)return;
|
||||
const sid=parseInt(match[1]);
|
||||
if(!sid){this.showToast('Invalid synced block','error');return;}
|
||||
const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
const sr=await fetch('/board/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
if(!sd||!sd.content){this.showToast('Synced block not found','error');return;}
|
||||
block.synced_id=sid;
|
||||
block._synced_content=sd.content||[];
|
||||
@@ -2545,4 +2545,34 @@ applyAIBlocks(text){
|
||||
if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
|
||||
// ── Ctrl/Cmd + S : sauvegarder le document ────────────────────────
|
||||
// L'autosave (1,5 s) suffit pour la frappe au quotidien, mais on ne peut
|
||||
// pas fermer l'onglet « au bout d'un moment » en etant sur que tout est
|
||||
// parti. Le raccourci est attendu par tout le monde : il appelle le même
|
||||
// `save()` que l'autosave (meme endpoint, meme file hors-ligne) et rend
|
||||
// la main explicitement.
|
||||
// `window.E` survit aux navigations partielles : on exige donc que
|
||||
// l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon
|
||||
// Ctrl+S sauvegarderait un editeur detache depuis une autre page.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
||||
if (String(e.key).toLowerCase() !== 's') return;
|
||||
var E = window.E;
|
||||
if (!E || !document.getElementById('_blocksCt') || typeof E.save !== 'function') return;
|
||||
e.preventDefault(); // sinon le navigateur ouvre « Enregistrer la page »
|
||||
if (E.canEdit === false) {
|
||||
if (E.showToast) E.showToast('This page is locked (read-only)', 'error');
|
||||
return;
|
||||
}
|
||||
if (E.saving) { if (E.showToast) E.showToast('Saving…'); return; }
|
||||
E.dirty = true;
|
||||
var notify = function () {
|
||||
if (E.showToast) E.showToast('Saved', 'success');
|
||||
else if (window.showToast) window.showToast('Saved', 'success');
|
||||
};
|
||||
try { E.save(notify); } catch (err) {
|
||||
if (E.showToast) E.showToast('Save failed', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — « Convertir en base de tâches »
|
||||
*
|
||||
* Modale de linkage : pour les trois rôles requis (Assigné à / Statut /
|
||||
* Échéance) on choisit soit une colonne existante compatible, soit on en
|
||||
* crée une nouvelle avec le bon type. Le serveur fait foi
|
||||
* (`POST /db/{id}/task-db/api`) : il valide les types, refuse le double
|
||||
* emploi d'une colonne et applique la limite de 10 bases connectées.
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__fdTaskDbLinkLoaded) return;
|
||||
window.__fdTaskDbLinkLoaded = true;
|
||||
|
||||
var ROLE_LABELS = { assignee: 'Assigné à', status: 'Statut', due: "Date d'échéance" };
|
||||
var ROLE_HINTS = {
|
||||
assignee: 'Colonne « personne » utilisée pour filtrer vos tâches.',
|
||||
status: 'Colonne statut ou sélection (À faire / En cours / Terminée).',
|
||||
due: 'Colonne de type date, utilisée par les vues et le calendrier.'
|
||||
};
|
||||
var DEFAULT_NAMES = { assignee: 'Assigné à', status: 'Statut', due: "Échéance" };
|
||||
var TYPE_LABELS = {
|
||||
person: 'personne', status: 'statut', select: 'sélection', date: 'date',
|
||||
text: 'texte', number: 'nombre', checkbox: 'case à cocher'
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function csrf() {
|
||||
return (typeof window.getCsrf === 'function') ? window.getCsrf() : '';
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = { 'X-CSRF-Token': csrf() };
|
||||
if (opts.body) headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status));
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Modale ─────────────────────────────────────────────────────── */
|
||||
|
||||
function roleRow(state, role) {
|
||||
var selected = state.mapping[role];
|
||||
var current = state.candidates[role] || [];
|
||||
// La colonne déjà mappée doit rester sélectionnable même si elle est
|
||||
// de type compatible mais exclue des candidats (déjà affectée).
|
||||
var all = state.all_properties || [];
|
||||
if (selected && !current.some(function (p) { return p.id === selected; })) {
|
||||
var kept = all.filter(function (p) { return p.id === selected; })[0];
|
||||
if (kept) current = [kept].concat(current);
|
||||
}
|
||||
|
||||
var h = '<div class="tdb-role" data-role="' + role + '">';
|
||||
h += '<div class="tdb-role-head"><label>' + esc(ROLE_LABELS[role]) + '</label>';
|
||||
h += '<span class="tdb-role-hint">' + esc(ROLE_HINTS[role]) + '</span></div>';
|
||||
h += '<div class="tdb-role-body">';
|
||||
h += '<select class="tdb-select" data-tdb-role="' + role + '">';
|
||||
h += '<option value="">— créer une nouvelle colonne —</option>';
|
||||
current.forEach(function (p) {
|
||||
h += '<option value="' + p.id + '"' +
|
||||
(String(selected) === String(p.id) ? ' selected' : '') + '>' +
|
||||
esc(p.name) + ' (' + esc(TYPE_LABELS[p.prop_type] || p.prop_type) +
|
||||
')</option>';
|
||||
});
|
||||
h += '</select>';
|
||||
h += '<input class="tdb-new-name" data-tdb-new="' + role + '" type="text" ' +
|
||||
'placeholder="' + esc(DEFAULT_NAMES[role]) + '" ' +
|
||||
(selected ? 'style="display:none"' : '') + '>';
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function openModal(collectionId) {
|
||||
closeModal();
|
||||
api('/db/' + collectionId + '/task-db/api').then(function (state) {
|
||||
if (!state.exists) return;
|
||||
|
||||
var roles = ['assignee', 'status', 'due'];
|
||||
var h = '<div class="tdb-overlay" id="tdb-overlay"><div class="tdb-modal">';
|
||||
h += '<div class="tdb-head"><span>' +
|
||||
(state.is_task ? 'Base de tâches' : 'Convertir en base de tâches') +
|
||||
'</span><button class="tdb-close" id="tdb-close">✕</button></div>';
|
||||
h += '<div class="tdb-intro">Pour alimenter <a href="/my-tasks">My Tasks</a>, ' +
|
||||
'reliez les trois colonnes ci-dessous. Une colonne absente sera créée ' +
|
||||
'avec le bon type.</div>';
|
||||
h += '<div class="tdb-body">';
|
||||
roles.forEach(function (r) { h += roleRow(state, r); });
|
||||
h += '</div>';
|
||||
if (state.sources_count >= state.max_sources) {
|
||||
h += '<div class="tdb-warn">⚠ Limite de ' + state.max_sources +
|
||||
' bases connectées atteinte : déconnectez-en une avant.</div>';
|
||||
}
|
||||
h += '<div class="tdb-foot">';
|
||||
if (state.is_task) {
|
||||
h += '<button class="tdb-btn-danger" id="tdb-disable">' +
|
||||
'Disconnecter</button>';
|
||||
}
|
||||
h += '<span class="tdb-spacer"></span>';
|
||||
h += '<button class="tdb-btn" id="tdb-cancel">Annuler</button>';
|
||||
h += '<button class="tdb-btn-primary" id="tdb-save"' +
|
||||
(state.sources_count >= state.max_sources && !state.is_task
|
||||
? ' disabled' : '') + '>' +
|
||||
(state.is_task ? 'Mettre à jour' : 'Convertir') + '</button>';
|
||||
h += '</div></div></div>';
|
||||
document.body.insertAdjacentHTML('beforeend', h);
|
||||
wire(state, collectionId);
|
||||
}).catch(function (e) { alert(e.message); });
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
var ov = document.getElementById('tdb-overlay');
|
||||
if (ov) ov.remove();
|
||||
}
|
||||
|
||||
function wire(state, collectionId) {
|
||||
var overlay = document.getElementById('tdb-overlay');
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
document.getElementById('tdb-close').onclick = closeModal;
|
||||
document.getElementById('tdb-cancel').onclick = closeModal;
|
||||
|
||||
// Choisir une colonne masque le champ « nouvelle colonne ».
|
||||
overlay.addEventListener('change', function (e) {
|
||||
var role = e.target.dataset.tdbRole;
|
||||
if (!role) return;
|
||||
var input = overlay.querySelector('[data-tdb-new="' + role + '"]');
|
||||
if (input) input.style.display = e.target.value ? 'none' : '';
|
||||
});
|
||||
|
||||
var disable = document.getElementById('tdb-disable');
|
||||
if (disable) {
|
||||
disable.onclick = function () {
|
||||
if (!confirm('Disconnecter cette base de My Tasks ?\n\n' +
|
||||
'Les tâches restent dans la base.')) return;
|
||||
api('/db/' + collectionId + '/task-db/api', { method: 'DELETE' })
|
||||
.then(function () { closeModal(); toast('Base déconnectée de My Tasks'); })
|
||||
.catch(function (e) { alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('tdb-save').onclick = function () {
|
||||
var mapping = {}, create_missing = {};
|
||||
['assignee', 'status', 'due'].forEach(function (role) {
|
||||
var sel = overlay.querySelector('[data-tdb-role="' + role + '"]');
|
||||
var val = sel ? sel.value : '';
|
||||
if (val) {
|
||||
mapping[role] = Number(val);
|
||||
} else {
|
||||
var input = overlay.querySelector('[data-tdb-new="' + role + '"]');
|
||||
create_missing[role] = {
|
||||
name: (input && input.value.trim()) || DEFAULT_NAMES[role]
|
||||
};
|
||||
}
|
||||
});
|
||||
api('/db/' + collectionId + '/task-db/api',
|
||||
{ method: 'POST', body: { mapping: mapping, create_missing: create_missing } })
|
||||
.then(function (d) {
|
||||
closeModal();
|
||||
toast(d.configured
|
||||
? '✅ Base connectée à My Tasks'
|
||||
: '⚠ Mapping incomplet — des colonnes manquent');
|
||||
})
|
||||
.catch(function (e) { alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
var t = document.createElement('div');
|
||||
t.textContent = msg;
|
||||
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' +
|
||||
'z-index:1200;background:var(--bg-modal,#fff);color:var(--text-primary,#333);' +
|
||||
'border:1px solid var(--border,#e3e3e0);border-radius:8px;padding:9px 16px;' +
|
||||
'font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.16)';
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function () { t.remove(); }, 3200);
|
||||
}
|
||||
|
||||
/* ── Point d'entrée depuis la barre de la base ──────────────────── */
|
||||
|
||||
window.FlowDeckTaskDb = { open: openModal, close: closeModal };
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var trigger = e.target.closest ? e.target.closest('[data-task-db]') : null;
|
||||
if (!trigger) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var id = trigger.dataset.taskDb;
|
||||
if (id) openModal(Number(id));
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user