fix: A27 phase 1 — 4 243 L de JS inline extraites + eslint actif (v7.21.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s

Extraction des 7 templates dont le JS n'est PAS interpolé Jinja → 9 fichiers
static/js/*.js (4 243 lignes, -30 % du JS inline : 13 904 → 9 661) :
- agent_panel_1/_2 (bloc de 1 788 L livré sur CHAQUE page), library (1 039),
  gitea_workspace (626), _icon_picker_1/_2, _ctx_menu, import, workspaces
- UN fichier par bloc : ordre/timing identiques (pas de defer, attributs
  conservés dont data-cfasync), cache-busting via ?v={{ asset_version }}
  (source unique A40), scripts externes = 'self' en CSP (pas de nonce requis)
- garde-fou : le script refuse tout bloc contenant {{ ou {%
- vérifs : node --check vert sur les 9, 0 script inline restant dans les
  cibles, suite complète 1089/1089

Lint (la moitié « ajouter les templates à eslint » de l'audit) :
- eslint.config.mjs existait (flat v9, sans dépendances npm) mais AUCUN
  binaire eslint n'était installé → npm i -g eslint
- `eslint static/js` → 0 erreur, 120 warnings (no-unused-vars 69,
  no-empty 36, no-undef 15) sur 8 fichiers = baseline à nettoyer
- les extraits sont couverts d'office par la config (static/js/**/*.js)

Reste A27 : blocs interpolés Jinja (page_editor 2 517, local_workspace 2 031,
base 1 523, database_table 1 323, settings 1 093, realtime 531 ≈ 9 661 L)
→ extraction en 2 temps (config JSON injectée + script statique).

suite **1089/1089** · ruff OK · docs à jour
This commit is contained in:
2026-10-01 15:55:09 -04:00
parent 7a38ddd0f6
commit 587ec8d61b
22 changed files with 4289 additions and 4258 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.20.0",
version="7.21.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -166
View File
@@ -15,172 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this._cx = cx;
this._cy = cy;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
Un ResizeObserver relance le placement à chaque fois que le menu
change de taille (ouverture d'un sous-menu « tag », icônes, …),
sinon il grandissait vers le bas et sortait de l'écran. */
var after = function () {
self._place();
var el = document.querySelector('.fd-ctx-menu');
if (el && window.ResizeObserver) {
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
self._ro = new ResizeObserver(function () { self._place(); });
self._ro.observe(el);
}
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
else setTimeout(after, 0);
},
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
disponible sous son ancre (le contenu déborde en scroll interne). */
_place() {
var el = document.querySelector('.fd-ctx-menu');
if (!el) return;
var prev = el.style.maxHeight;
el.style.maxHeight = 'none';
var w = el.offsetWidth || 240;
var h = el.offsetHeight || 320;
el.style.maxHeight = prev || '';
var vw = window.innerWidth, vh = window.innerHeight;
var cx = (this._cx == null ? this.x : this._cx);
var cy = (this._cy == null ? this.y : this._cy);
var nx = cx;
if (nx + w > vw - 8) nx = vw - w - 8;
nx = Math.max(8, nx);
var ny = cy;
if (ny + h > vh - 8) {
if (cy - h >= 8) ny = cy - h;
else ny = Math.max(8, vh - h - 8);
}
this.x = nx;
this.y = ny;
this.maxH = Math.max(120, vh - ny - 8);
},
close() {
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
openIconPicker() {
var fn = this.handlers.setIcon;
if (!fn) return;
if (!window.FDIconPicker) return;
window.FDIconPicker.openFor({
x: this.x,
y: this.y,
onPick: fn,
onRemove: function () { fn(''); }
});
this.close();
},
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
<style>
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
+2 -299
View File
@@ -10,306 +10,9 @@
############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
};
/* Render a page_icon value: image URL, known icon name, or emoji text. */
window.fdIconHtml = function (value, size) {
size = size || 16;
var v = (value == null ? '' : String(value)).trim();
if (!v) return '';
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
return '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
var TONEABLE = {};
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
var CATS = [
{ id: 'people', label: 'People', items: [
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
]},
{ id: 'nature', label: 'Nature', items: [
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
]},
{ id: 'food', label: 'Food', items: [
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
]},
{ id: 'activity', label: 'Activity', items: [
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
]},
{ id: 'travel', label: 'Travel', items: [
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
]},
{ id: 'objects', label: 'Objects', items: [
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
]},
{ id: 'symbols', label: 'Symbols', items: [
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
]},
{ id: 'flags', label: 'Flags', items: [
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️‍🌈','rainbow flag pride'],['🏴‍☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
]}
];
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdIconPicker) return;
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
cats: CATS,
openFor(opts) {
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
this.query = '';
this.toneOpen = false;
this.customModal = false;
this._cx = (opts.x != null) ? opts.x : 240;
this._cy = (opts.y != null) ? opts.y : 200;
this.x = this._cx;
this.y = this._cy;
this.open = true;
var self = this;
var place = function () {
var el = document.querySelector('.fd-icon-picker');
if (!el) return;
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = self._cx, ny = self._cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
};
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
else setTimeout(place, 0);
this.loadRecent();
this.loadCustom();
},
close() {
this.open = false;
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
this.onRemove = null;
},
matches(name) {
var q = (this.query || '').trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().indexOf(q) >= 0;
},
items() {
var q = (this.query || '').trim().toLowerCase();
if (q) {
var out = [];
this.cats.forEach(function (c) {
c.items.forEach(function (it) {
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
});
});
return out;
}
if (this.category === 'recent') return this.recent;
var found = [];
for (var i = 0; i < this.cats.length; i++) {
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
}
return found;
},
withTone(e) {
if (!this.skinTone) return e;
if (TONEABLE[e]) return e + TONES[this.skinTone];
return e;
},
pick(v) {
v = (v || '').trim();
if (!v) return;
this.pushRecent(v);
var fn = this.onPick;
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
this.close();
},
remove() {
var fn = this.onRemove || this.onPick;
if (fn) { try { fn(''); } catch (e) {} }
this.close();
},
random() {
var pool = [];
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
if (!pool.length) return;
this.pick(pool[Math.floor(Math.random() * pool.length)]);
},
setTone(i) { this.skinTone = i; this.toneOpen = false; },
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
loadRecent() {
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
catch (e) { this.recent = []; }
},
pushRecent(e) {
try {
var r = this.recent.filter(function (x) { return x !== e; });
r.unshift(e);
this.recent = r.slice(0, 32);
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
} catch (err) {}
},
async loadCustom() {
try {
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
var d = await r.json();
this.custom = (d && d.emojis) || [];
this.customLoaded = true;
} catch (e) { this.custom = []; }
},
openCustomModal() {
this.customModal = true;
this.customName = '';
this.customPreview = '';
this.customFile = null;
this.tab = 'upload';
},
closeCustomModal() { this.customModal = false; },
onCustomFile(ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
this.customFile = f;
var self = this;
var fr = new FileReader();
fr.onload = function () { self.customPreview = fr.result; };
fr.readAsDataURL(f);
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
},
async saveCustom() {
if (!this.customFile || this.customBusy) return;
this.customBusy = true;
try {
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
var d = await r.json();
if (d && d.emoji) {
this.custom.unshift(d.emoji);
this.customModal = false;
this.pick(d.emoji.url);
}
} catch (e) {
if (window.showToast) window.showToast('Emoji upload failed', 'error');
}
this.customBusy = false;
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
this.custom = this.custom.filter(function (e) { return e.id !== id; });
} catch (e) {}
}
});
});
window.FDIconPicker = {
openFor: function (opts) {
var s = window.Alpine && Alpine.store('fdIconPicker');
if (s) s.openFor(opts);
}
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
File diff suppressed because it is too large Load Diff
+1 -627
View File
@@ -35,633 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
const repo = params.get('repo') || '';
return {
owner, repo,
showFile: false,
showEditor: false,
showPrivate: false,
privatePages: [],
editingPrivate: null,
editingPrivateTitle: '',
editingPrivateContent: '',
filePath: '',
fileContent: '',
fileSize: 0,
fileSha: '',
fileLoading: false,
fileLanguage: 'text',
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
// File tree browser
treeItems: [],
sortedTreeItems: [],
treeLoading: false,
folderStack: [],
currentPath: '',
// Context menu
gwCtx: { visible: false, x: 0, y: 0, item: null },
_sortTree() {
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
},
init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load sidebar tree (into gitea section)
this.loadSidebarTree();
// Load main content tree
this.loadMainTree('');
// Listen for sidebar clicks on Gitea items
this.setupSidebarClicks();
},
async loadMainTree(path) {
this.treeLoading = true;
this.currentPath = path;
try {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
if (path) url += '?path=' + encodeURIComponent(path);
var r = await fetch(url);
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
var d = await r.json();
this.treeItems = d.tree || [];
this._sortTree();
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
finally { this.treeLoading = false; }
},
drillDown(path) {
this.folderStack.push(path.split('/').pop());
this.loadMainTree(path);
},
navigateToFolder(idx) {
// Truncate stack and rebuild path
this.folderStack = this.folderStack.slice(0, idx + 1);
var path = this.folderStack.join('/');
this.loadMainTree(path);
},
navigateToRoot() {
this.folderStack = [];
this.loadMainTree('');
},
openGwContext(ev, item) {
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
},
gwRename() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
var newName = prompt('Rename:', item.name);
if (!newName || !newName.trim() || newName.trim() === item.name) return;
var self = this;
var oldPath = item.path;
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') { self.refreshTree(); }
else { window.showToast('Rename failed', 'error'); }
})
.catch(function(){ window.showToast('Rename failed', 'error'); });
},
gwDelete() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
else { window.showToast('Delete failed', 'error'); }
}).catch(function(){ window.showToast('Delete failed', 'error'); });
},
async loadSidebarTree() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return;
}
var d = await r.json();
var items = d.tree || [];
var container = document.getElementById('sidebar-gitea-items');
if (!container) return;
// Ensure gitea section is visible
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
window.appState.sectionsOpen.gitea = true;
}
container.innerHTML = '';
// Build tree HTML as string and set once (more performant)
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html;
// Re-attach event listeners
this.setupSidebarClicks();
} catch(e) {
console.error('Gitea sidebar tree load failed:', e);
}
},
setupSidebarClicks() {
var self = this;
document.addEventListener('click', function(e) {
var el = e.target.closest('.sidebar-item[data-gitea-path]');
if (!el) return;
var path = el.getAttribute('data-gitea-path');
var type = el.getAttribute('data-gitea-type');
// If clicking the checkbox, let its own handler deal with selection
if (e.target.classList.contains('gitea-checkbox')) return;
// If clicking the inline rename input, don't navigate
if (e.target.classList.contains('inline-rename-input')) return;
// If Shift or Ctrl/Meta is pressed, use multi-selection
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
self.selectItem(path, el, e);
return;
}
// Normal click: navigate (open file/folder)
e.preventDefault();
e.stopPropagation();
// Update visual "active" state
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
si.classList.remove('active');
});
el.classList.add('active');
if (type === 'folder') {
self.loadSubdir(path, el);
} else {
self.openFile(path, el.getAttribute('data-gitea-sha'));
}
});
// Listen for custom delete event on gitea sidebar items
document.addEventListener('gitea-delete', function(e) {
var el = e.target;
var path = el.getAttribute('data-gitea-path');
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
}).catch(function() {});
});
},
async loadSubdir(path, el) {
var self = this;
// Check if already loaded
var ul = el.querySelector('ul');
if (ul) {
ul.style.display = ul.style.display === 'none' ? '' : 'none';
return;
}
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
if (!r.ok) return;
var d = await r.json();
var children = d.tree || [];
ul = document.createElement('ul');
ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px';
children.forEach(function(child) {
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li');
li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', child.path);
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', child.sha || '');
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
// Checkbox
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'gitea-checkbox';
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) {
ev.stopPropagation();
self.selectItem(child.path, li, ev);
});
li.appendChild(cb);
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'sidebar-icon';
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
li.appendChild(iconSpan);
// Name
var nameSpan = document.createElement('span');
nameSpan.textContent = child.name;
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
self.startInlineRename(nameSpan, child.path, li);
});
li.appendChild(nameSpan);
ul.appendChild(li);
});
el.appendChild(ul);
} catch(e) {}
},
async openFile(path, sha) {
this.filePath = path;
this.fileSha = sha || '';
this.showEditor = false;
this.showFile = true;
this.fileLoading = true;
this.fileLanguage = this.getLanguage(path);
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
if (r.ok) {
var d = await r.json();
this.fileContent = d.content || '';
this.fileSize = d.content ? d.content.length : 0;
} else {
this.fileContent = '[Error loading file]';
}
} catch(e) {
this.fileContent = '[Error loading file]';
}
this.fileLoading = false;
// Highlight after Alpine renders
var self = this;
this.$nextTick(function() {
var block = self.$refs.codeBlock;
if (block && block.textContent && typeof Prism !== 'undefined') {
Prism.highlightElement(block);
}
});
},
getLanguage(path) {
var ext = (path || '').split('.').pop().toLowerCase();
var map = {
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
};
return map[ext] || 'text';
},
openEditor() {
this.editContent = this.fileContent;
this.showEditor = true;
},
async saveFile() {
if (!this.filePath) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({
path: this.filePath, content: this.editContent,
message: this.commitMessage, sha: this.fileSha,
})
});
if (r.ok) {
this.fileContent = this.editContent;
this.showEditor = false;
if (!this.commitHistory.includes(this.commitMessage)) {
this.commitHistory.unshift(this.commitMessage);
if (this.commitHistory.length > 10) this.commitHistory.pop();
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
}
} else {
var d = await r.json();
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Network error', 'error'); }
},
async deleteCurrentFile() {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
});
if (r.ok) {
this.showFile = false;
this.filePath = '';
await this.refreshTree();
}
} catch(e) {}
},
async refreshTree() {
// Reload main tree and sidebar tree without full page reload
this.loadMainTree(this.currentPath);
this.loadSidebarTree();
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
},
// ── Private Pages ──
async loadPrivatePages() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
} catch(e) {}
},
newPrivate() {
this.editingPrivate = 'new';
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
async openPrivate(id) {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
if (r.ok) {
var d = await r.json();
this.editingPrivate = id;
this.editingPrivateTitle = d.page.title;
this.editingPrivateContent = d.page.content;
}
} catch(e) {}
},
async savePrivate() {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
var method = 'POST';
if (this.editingPrivate !== 'new') {
url += '/' + this.editingPrivate;
method = 'PUT';
}
try {
var r = await fetch(url, {
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
});
if (r.ok) {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
await this.loadPrivatePages();
}
} catch(e) {}
},
// Create new file
showNewFile: false,
newFilePath: '',
newFileContent: '',
newFileMsg: 'Create via FlowDeck',
async createNewFile() {
if (!this.newFilePath.trim()) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
});
if (r.ok) {
this.showNewFile = false;
this.newFilePath = '';
this.newFileContent = '';
this.newFileMsg = 'Create via FlowDeck';
await this.loadSidebarTree();
} else {
var d = await r.json();
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
},
// Upload files
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
async doUpload(ev) {
var files = ev.target.files;
if (!files.length) return;
for (var i = 0; i < files.length; i++) {
var form = new FormData();
form.append('file', files[i]);
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
method: 'POST',
headers: {'X-CSRF-Token': this.getCsrfToken()},
body: form
});
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
} catch(e) { console.error('Upload error:', e); }
}
await this.loadSidebarTree();
ev.target.value = '';
},
async deletePrivate(id) {
if (!confirm('Delete this private page?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
});
if (r.ok) await this.loadPrivatePages();
} catch(e) {}
},
getCsrfToken() {
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
},
// ── Multi-selection ──
multiSelect: false,
selectedPaths: [],
lastClickedPath: null,
toggleMultiSelect() {
this.multiSelect = !this.multiSelect;
var cbs = document.querySelectorAll('.gitea-checkbox');
var self = this;
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
if (!this.multiSelect) {
// Clear selection when leaving multi-select mode
cbs.forEach(function(cb) { cb.checked = false; });
this.selectedPaths = [];
this.lastClickedPath = null;
// Remove selected class
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
}
},
selectItem(path, li, ev) {
if (ev.shiftKey && this.lastClickedPath) {
// Range select
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
if (startIdx >= 0 && endIdx >= 0) {
var lo = Math.min(startIdx, endIdx);
var hi = Math.max(startIdx, endIdx);
this.selectedPaths = [];
for (var i = lo; i <= hi; i++) {
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
all[i].classList.add('gitea-selected');
var cb = all[i].querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
}
this.multiSelect = true;
this.toggleMultiSelect(); // ensure checkboxes are visible
} else if (ev.ctrlKey || ev.metaKey) {
// Toggle single
var idx = this.selectedPaths.indexOf(path);
if (idx >= 0) {
this.selectedPaths.splice(idx, 1);
li.classList.remove('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = false;
} else {
this.selectedPaths.push(path);
li.classList.add('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
this.multiSelect = this.selectedPaths.length > 0;
this.toggleMultiSelect();
} else {
// Normal select — clear multi-selection
this.selectedPaths = [path];
this.lastClickedPath = path;
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
li.classList.add('gitea-selected', 'active');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
},
isSelected(path) {
return this.selectedPaths.indexOf(path) >= 0;
},
// ── Inline rename ──
startInlineRename(nameSpan, path, li) {
var originalName = nameSpan.textContent;
var input = document.createElement('input');
input.type = 'text';
input.value = originalName;
input.className = 'inline-rename-input';
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
nameSpan.replaceWith(input);
input.focus();
input.select();
var self = this;
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
var cancel = function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
};
input.addEventListener('blur', finish);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') finish();
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
});
},
finishInlineRename(input, originalName, path, li) {
if (input._renaming) return; // guard against double-fire
input._renaming = true;
var newName = input.value.trim();
if (!newName || newName === originalName) {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
return;
}
var self = this;
// Construct new path by replacing the last segment
var parts = path.split('/');
parts.pop();
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
}).then(function(r) {
if (r.ok) {
self.refreshTree();
} else {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
window.showToast('Rename failed', 'error');
}
}).catch(function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
});
},
};
});
});
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
+1 -159
View File
@@ -210,164 +210,6 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div>
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function importWizard() {
return {
sources: [],
sourceId: '',
files: [],
dragOver: false,
mode: 'skip',
busy: false,
progress: 0,
error: '',
preview: null,
report: null,
mapping: {},
csrf: '',
urlValue: '',
urlError: '',
forgeProvider: 'gitea',
forgeOwner: '',
forgeRepo: '',
forgeState: 'all',
forgeError: '',
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
async init() {
try {
const r = await fetch('/api/import/sources');
this.sources = (await r.json()).sources || [];
} catch(e) {}
try {
const c = await fetch('/api/csrf-token');
this.csrf = (await c.json()).csrf_token;
} catch(e) {}
},
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
addFiles(list) {
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
this.preview = null; this.report = null; this.error = '';
},
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
buildForm(f) {
const fd = new FormData();
fd.append('file', f.file);
if (this.sourceId) fd.append('source', this.sourceId);
fd.append('mode', this.mode);
return fd;
},
async doPreview() {
this.busy = true; this.error=''; this.report=null; this.progress=10;
try {
let merged = null;
for (let i=0;i<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
},
async importOne(f) {
f.status = 'running';
const fd = this.buildForm(f);
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
const big = f.size > 5*1024*1024;
try {
if (big) {
fd.append('async','true');
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
return await this.pollJob(d.job_id, f);
}
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
f.status = (d.status==='partial' ? 'partial' : 'done');
return d;
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
},
async doImport() {
this.busy = true; this.error=''; this.report=null; this.progress=0;
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
for (let i=0;i<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => setTimeout(res, 700));
const r = await fetch('/api/import/jobs/'+jobId);
const j = await r.json();
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
}
if (f) { f.status='error'; f.error='Délai dépassé'; }
return null;
},
downloadReport() {
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'flowdeck-import-report.json';
a.click();
URL.revokeObjectURL(a.href);
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1 -154
View File
@@ -172,158 +172,5 @@
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
wsName: '',
renameTarget: null,
// Gitea
giteaStatus: 'loading', // loading|ok|not_linked|error
giteaGroups: [],
giteaTotal: 0,
activeOrg: 'all',
giteaSearch: '',
get filteredGroups() {
var self = this;
var q = (this.giteaSearch || '').toLowerCase();
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
if (!q) return groups;
return groups.map(function(g) {
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
})};
}).filter(function(g) { return g.projects.length > 0; });
},
async init() {
await this.load();
await this.loadGitea();
},
async load() {
const r = await fetch('/api/workspaces');
const d = await r.json();
this.workspaces = d.workspaces || [];
this.activeId = d.active_id;
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
window.location = '/local-workspace';
},
async doCreate() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showCreate = false;
await this.load();
},
renameWs(ws) {
this.renameTarget = ws;
this.wsName = ws.name;
this.showRename = true;
},
async doRename() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showRename = false;
this.renameTarget = null;
await this.load();
},
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await this.load();
},
// ── Gitea ──
async loadGitea() {
this.giteaStatus = 'loading';
try {
// Get orgs
const orgsRes = await fetch('/api/gitea/orgs');
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
const orgsData = await orgsRes.json();
const orgs = orgsData.orgs || [];
// Fetch repos: user repos + org repos
const groups = [];
// Get username for the "personal" tab
let myLogin = 'Me';
try {
const meRes = await fetch('/auth/user');
if (meRes.ok) {
const meData = await meRes.json();
myLogin = meData.user?.login || 'Me';
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
}
} catch(e) {}
// User repos (no org filter)
try {
const userRes = await fetch('/api/gitea/projects');
if (userRes.ok) {
const d = await userRes.json();
const repos = d.projects || [];
if (repos.length) {
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
}
}
} catch(e) {}
// Org repos
for (const org of orgs) {
try {
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
if (res.ok) {
const d = await res.json();
if (d.projects && d.projects.length) {
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
}
}
} catch(e) {}
}
this.giteaGroups = groups;
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
this.giteaStatus = 'ok';
} catch(e) {
this.giteaStatus = 'error';
}
},
openGitea(proj) {
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
const repo = proj.name;
if (owner && repo) {
// Set workspace cookie so sidebar shows tree
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/workspaces.js?v={{ asset_version }}"></script>
{% endblock %}