feat(icon): Notion-style icon picker + custom workspace emojis; fix side peek file content & ctx menu overflow
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Failing after 3h6m55s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-12 20:05:53 -04:00
parent 19e8ba0e4a
commit da3e09c215
16 changed files with 766 additions and 53 deletions
+2
View File
@@ -38,6 +38,7 @@ from app.routers import (
)
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.notifications import router as notifications_router
@@ -134,6 +135,7 @@ app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
app.include_router(agent.router)
app.include_router(search.router)
+19
View File
@@ -335,6 +335,25 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
@register(8, "v5.6.0: custom workspace emojis")
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS custom_emojis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
)
@register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
+2 -2
View File
@@ -1264,10 +1264,10 @@ async def remove_page_cover(request: Request, page_id: int):
@router.post("/api/pages/{page_id}/icon")
async def set_page_icon(request: Request, page_id: int):
"""v5.5.0: set a page emoji/icon label."""
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
body = await request.json()
icon = (body.get("icon") or "").strip()
if len(icon) > 16:
if len(icon) > 512:
raise HTTPException(400, "icon too long")
with get_conn() as conn:
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
+3 -1
View File
@@ -1394,8 +1394,10 @@ async def restore_local_workspace_item(request: Request, item_id: int):
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
import os
from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
fp.relative_to(base_dir)
+100
View File
@@ -0,0 +1,100 @@
"""FlowDeck — custom workspace emojis (v5.6.0).
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
"""
from __future__ import annotations
import datetime
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
router = APIRouter(tags=["emojis"])
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _active_ws(request: Request) -> int:
"""Workspace id from the active-workspace cookie, fallback 1."""
try:
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
return 1
@router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis."""
ws_id = _active_ws(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
(ws_id,),
).fetchall()
return {"status": "ok", "emojis": [dict(r) for r in rows]}
@router.post("/api/custom-emojis")
async def create_custom_emoji(request: Request):
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
form = await request.form()
name = (form.get("name") or "").strip()[:40] or "emoji"
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
(folder / final).write_bytes(await upload.read())
url = f"/api/files/{ws_id}/{final}"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
(ws_id, name, url),
)
conn.commit()
emoji_id = cur.lastrowid
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
@router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request)
with get_conn() as conn:
row = conn.execute(
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
(emoji_id, ws_id),
).fetchone()
if not row:
raise HTTPException(404, "emoji not found")
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
conn.commit()
try:
fname = (row["url"] or "").rsplit("/", 1)[-1]
if fname:
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
except OSError:
pass
return {"status": "ok", "id": emoji_id}
+59 -29
View File
@@ -33,6 +33,7 @@
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,
@@ -56,27 +57,56 @@
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. */
var place = function () {
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');
var w = el ? el.offsetWidth : 240;
var h = el ? el.offsetHeight : 320;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = cx, ny = 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 (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(place);
else setTimeout(place, 0);
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 = {};
@@ -119,11 +149,22 @@
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
if (!icon) return;
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();
},
});
});
})();
@@ -144,7 +185,7 @@
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;}
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;max-height:38vh;overflow-y:auto;}
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
@@ -161,7 +202,7 @@
@click.outside="$store.fdCtx.close()"
@keydown.escape.window="$store.fdCtx.close()"
x-transition
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px' }">
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px', maxHeight: $store.fdCtx.maxH ? $store.fdCtx.maxH + 'px' : 'calc(100vh - 16px)' }">
{# ── Open ── #}
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
@@ -195,21 +236,10 @@
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
</div>
{# ── Edit icon ── #}
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.iconOpen = !$store.fdCtx.iconOpen">
{# ── Edit icon (sélecteur façon Notion) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.openIconPicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
<span style="flex:1;">Edit icon</span><span class="ctx-caret" x-text="$store.fdCtx.iconOpen ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.iconOpen" class="ctx-sub">
<div class="ctx-icons">
<template x-for="ic in $store.fdCtx.iconChoices" :key="'ic'+ic">
<button type="button" class="ctx-icon" @click.stop="$store.fdCtx.setIcon(ic)" x-text="ic"></button>
</template>
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxIconInput" placeholder="Custom emoji…" @click.stop @keydown.enter.prevent="$store.fdCtx.setIcon($refs.ctxIconInput.value)">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.setIcon($refs.ctxIconInput.value)">Set</button>
</div>
<span style="flex:1;">Edit icon</span>
</div>
{# ── Duplicate ── #}
+1 -1
View File
@@ -51,7 +51,7 @@
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
{% if page_icon %}
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
<span class="header-page-icon">{{ fd_page_icon(page_icon, 18) }}</span>
{% endif %}
<template x-for="(crumb, di) in display" :key="di">
+467
View File
@@ -0,0 +1,467 @@
{###############################################################################
_icon_picker.html — Sélecteur d'icône/emoji façon Notion (v5.6.0)
═══════════════════════════════════════════════════════════════════════════
Popover global (inclus par base.html) utilisé par :
• le menu contextuel (library / local-workspace) via fdCtx.setIcon
• l'éditeur de page (clic sur l'icône du titre)
Expose le store Alpine `fdIconPicker` + `window.FDIconPicker`.
Onglets Emoji / Icons / Upload, recherche, aléatoire, teintes de peau,
récents (localStorage) et emojis personnalisés du workspace (API).
############################################################################}
{% 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">
/* 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">
(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>
<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;}
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-tab.active{background:var(--bg-hover);color:var(--text-primary);font-weight:600;}
.fd-icon-picker .fdip-remove{margin-left:auto;background:transparent;border:none;color:var(--text-secondary);font-size:12px;padding:5px 8px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-remove:hover{background:var(--bg-hover);color:var(--danger);}
.fd-icon-picker .fdip-tools{display:flex;align-items:center;gap:6px;padding:8px 10px;}
.fd-icon-picker .fdip-search{flex:1;min-width:0;font-size:13px;padding:6px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);outline:none;}
.fd-icon-picker .fdip-search:focus{border-color:var(--accent);}
.fd-icon-picker .fdip-tool{width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:15px;color:var(--text-primary);}
.fd-icon-picker .fdip-tool:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-tone-wrap{position:relative;}
.fd-icon-picker .fdip-tone-menu{position:absolute;right:0;top:34px;display:flex;gap:2px;padding:6px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);z-index:5;}
.fd-icon-picker .fdip-tone{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:16px;}
.fd-icon-picker .fdip-tone:hover,.fd-icon-picker .fdip-tone.selected{background:var(--bg-hover);}
.fd-icon-picker .fdip-body{flex:1;overflow-y:auto;padding:0 10px 6px;}
.fd-icon-picker .fdip-section{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);padding:6px 2px 2px;}
.fd-icon-picker .fdip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;}
.fd-icon-picker .fdip-emoji,.fd-icon-picker .fdip-icon{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:21px;line-height:1;color:var(--text-primary);position:relative;}
.fd-icon-picker .fdip-emoji:hover,.fd-icon-picker .fdip-icon:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-custom img{width:24px;height:24px;object-fit:contain;}
.fd-icon-picker .fdip-del{position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%;background:var(--danger);color:#fff;font-size:10px;line-height:14px;text-align:center;display:none;}
.fd-icon-picker .fdip-custom:hover .fdip-del{display:block;}
.fd-icon-picker .fdip-empty{color:var(--text-dim);font-size:12px;text-align:center;padding:24px 0;}
.fd-icon-picker .fdip-cats{display:flex;align-items:center;gap:2px;padding:6px 10px;border-top:1px solid var(--border);}
.fd-icon-picker .fdip-cat{flex:1;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;color:var(--text-secondary);}
.fd-icon-picker .fdip-cat:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-cat.active{background:var(--bg-hover);color:var(--accent);}
.fd-icon-picker .fdip-cat svg{width:16px;height:16px;}
.fdip-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2300;display:flex;align-items:center;justify-content:center;}
.fdip-modal{width:420px;max-width:92vw;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:20px;box-shadow:0 16px 48px rgba(0,0,0,.35);}
.fdip-modal-title{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:6px;}
.fdip-modal-desc{font-size:13px;color:var(--text-secondary);margin-bottom:16px;}
.fdip-upload-btn{display:inline-block;padding:8px 14px;font-size:13px;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--text-primary);background:var(--bg-secondary);}
.fdip-upload-btn:hover{background:var(--bg-hover);}
.fdip-preview-boxes{display:flex;gap:10px;margin:14px 0;}
.fdip-preview{width:64px;height:64px;border-radius:8px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);}
.fdip-preview.dark{background:#111;}
.fdip-preview.light{background:#fff;}
.fdip-preview img{max-width:44px;max-height:44px;object-fit:contain;}
.fdip-modal-label{display:block;font-size:12px;color:var(--text-secondary);margin:8px 0 4px;}
.fdip-modal-input{width:100%;font-size:14px;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;box-sizing:border-box;}
.fdip-modal-input:focus{border-color:var(--accent);}
.fdip-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;}
.fdip-modal-actions button{padding:8px 16px;font-size:13px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);}
.fdip-modal-actions button.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
.fdip-modal-actions button:disabled{opacity:.5;cursor:default;}
</style>
<div x-data>
<div class="fd-icon-picker" x-show="$store.fdIconPicker.open" x-cloak
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
@keydown.escape.window="$store.fdIconPicker.close()"
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
<div class="fdip-tabs">
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
<button class="fdip-remove" @click="$store.fdIconPicker.remove()">Remove</button>
</div>
<div class="fdip-tools" x-show="$store.fdIconPicker.tab !== 'upload'">
<input class="fdip-search" placeholder="Filter..." x-model="$store.fdIconPicker.query">
<button class="fdip-tool" title="Random" @click="$store.fdIconPicker.random()">🎲</button>
<div class="fdip-tone-wrap" x-show="$store.fdIconPicker.tab === 'emoji'">
<button class="fdip-tool" title="Select skin tone" @click.stop="$store.fdIconPicker.toneOpen = !$store.fdIconPicker.toneOpen">👋</button>
<div class="fdip-tone-menu" x-show="$store.fdIconPicker.toneOpen" @click.outside="$store.fdIconPicker.toneOpen = false">
<template x-for="(t, ti) in ['👋','👋🏻','👋🏼','👋🏽','👋🏾','👋🏿']" :key="ti">
<button class="fdip-tone" :class="{ selected: $store.fdIconPicker.skinTone === ti }" @click="$store.fdIconPicker.setTone(ti)" x-text="t"></button>
</template>
</div>
</div>
</div>
<div class="fdip-body">
<template x-if="$store.fdIconPicker.tab === 'emoji'">
<div>
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.items()" :key="e">
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
</template>
</div>
<div class="fdip-empty" x-show="!$store.fdIconPicker.items().length">No emoji found</div>
</div>
</template>
<template x-if="$store.fdIconPicker.tab === 'icons'">
<div>
<div class="fdip-grid">
{% for name in picker_icons %}
<button class="fdip-icon" x-show="$store.fdIconPicker.matches('{{ name }}')" @click="$store.fdIconPicker.pick('{{ name }}')" title="{{ name }}">{{ fd_icon(name, 20) }}</button>
{% endfor %}
</div>
</div>
</template>
<template x-if="$store.fdIconPicker.tab === 'upload'">
<div>
<div class="fdip-custom-head" style="display:flex;align-items:center;justify-content:space-between;padding:6px 2px;">
<span class="fdip-section" style="padding:0;">Custom emojis</span>
<button class="fdip-tool" style="width:auto;padding:0 10px;" @click="$store.fdIconPicker.openCustomModal()">+ Add</button>
</div>
<div class="fdip-grid">
<template x-for="ce in $store.fdIconPicker.custom" :key="ce.id">
<button class="fdip-emoji fdip-custom" @click="$store.fdIconPicker.pick(ce.url)" :title="ce.name">
<img :src="ce.url" :alt="ce.name">
<span class="fdip-del" @click.stop="$store.fdIconPicker.deleteCustom(ce.id)">×</span>
</button>
</template>
</div>
<div class="fdip-empty" x-show="!$store.fdIconPicker.custom.length">No custom emoji yet</div>
</div>
</template>
</div>
<div class="fdip-cats" x-show="$store.fdIconPicker.tab === 'emoji'">
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'recent' }" title="Recent" @click="$store.fdIconPicker.setCategory('recent')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'people' }" title="People" @click="$store.fdIconPicker.setCategory('people')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14s1.3 2 3.5 2 3.5-2 3.5-2"/><line x1="9" y1="9.5" x2="9.01" y2="9.5"/><line x1="15" y1="9.5" x2="15.01" y2="9.5"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'nature' }" title="Nature" @click="$store.fdIconPicker.setCategory('nature')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 4 13c0-6 7-10 16-10 0 9-4 16-10 16z"/><path d="M4 21c2-4 5-7 9-9"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'food' }" title="Food" @click="$store.fdIconPicker.setCategory('food')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21l6-6"/><path d="M14 3s5 1 7 7c-4 4-8 3-10 1s-1-6 3-8z"/><path d="M9 12l-4 4"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'activity' }" title="Activity" @click="$store.fdIconPicker.setCategory('activity')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 11V6a2 2 0 0 1 4 0v5"/><path d="M10 11V4a2 2 0 0 1 4 0v7"/><path d="M14 11V6a2 2 0 0 1 4 0v9a7 7 0 0 1-7 7H9a6 6 0 0 1-6-6v-3a2 2 0 0 1 3-1.7"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'travel' }" title="Travel" @click="$store.fdIconPicker.setCategory('travel')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 16l20-6-3-3-7 1-5-5-2 1 3 5-4 1-2-2-1 1z"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'objects' }" title="Objects" @click="$store.fdIconPicker.setCategory('objects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a6 6 0 0 0-4 10.5c.8.7 1 1.5 1 2.5h6c0-1 .2-1.8 1-2.5A6 6 0 0 0 12 2z"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'symbols' }" title="Symbols" @click="$store.fdIconPicker.setCategory('symbols')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'flags' }" title="Flags" @click="$store.fdIconPicker.setCategory('flags')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/></svg></button>
<button class="fdip-cat" title="Add custom emoji" @click="$store.fdIconPicker.openCustomModal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<div class="fdip-modal-overlay" x-show="$store.fdIconPicker.customModal" x-cloak @click.self="$store.fdIconPicker.closeCustomModal()">
<div class="fdip-modal">
<div class="fdip-modal-title">Add custom emoji</div>
<div class="fdip-modal-desc">Custom emoji can be used by anyone in your workspace.</div>
<label class="fdip-upload-btn">
<span x-show="!$store.fdIconPicker.customPreview">Upload an image</span>
<span x-show="$store.fdIconPicker.customPreview">Replace</span>
<input type="file" accept="image/*" @change="$store.fdIconPicker.onCustomFile($event)" style="display:none">
</label>
<div class="fdip-preview-boxes" x-show="$store.fdIconPicker.customPreview">
<div class="fdip-preview dark"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
<div class="fdip-preview light"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
</div>
<label class="fdip-modal-label">Emoji name</label>
<input class="fdip-modal-input" x-model="$store.fdIconPicker.customName" placeholder="pushup">
<div class="fdip-modal-actions">
<button @click="$store.fdIconPicker.closeCustomModal()">Cancel</button>
<button class="primary" :disabled="$store.fdIconPicker.customBusy || !$store.fdIconPicker.customFile" @click="$store.fdIconPicker.saveCustom()">Save</button>
</div>
</div>
</div>
</div>
+12
View File
@@ -114,3 +114,15 @@
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{%- endif -%}
{%- endmacro -%}
{#- Renders a page_icon value: custom-emoji image URL, known icon name, or emoji. -#}
{%- macro fd_page_icon(value, size=18) -%}
{%- set v = (value or '')|string|trim -%}
{%- if v.startswith('/') or v.startswith('http') -%}
<img src="{{ v }}" alt="" style="width:{{ size }}px;height:{{ size }}px;object-fit:contain;vertical-align:middle;display:inline-block;">
{%- elif v in ("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") -%}
{{ fd_icon(v, size) }}
{%- else -%}
{{ v }}
{%- endif -%}
{%- endmacro -%}
+1 -1
View File
@@ -508,7 +508,7 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<div
class="page-title-input"
contenteditable="true"
+14 -1
View File
@@ -1195,12 +1195,25 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
iconHtml(){
var v=this.iconValue;
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
return window.fdIconHtml?window.fdIconHtml(v,36):v;
},
openIconPicker(){
var self=this;
if(window.FDIconPicker)window.FDIconPicker.openFor({
x:120,y:160,
onPick:function(v){self.setIcon(v);},
onRemove:function(){self.setIcon('');}
});
},
async setIcon(icon){
this.iconValue=icon;
try{
const r=await fetch('/board/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
const d=await r.json();
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
if(d.status==='ok'){this.iconOpen=false;this.showToast(icon?'Icon updated':'Icon removed');}
}catch(e){this.showToast('Icon update failed','error');}
},
+5 -6
View File
@@ -142,7 +142,7 @@
{% else %}
<span class="tree-toggle-placeholder"></span>
{% endif %}
<span class="page-icon">{% if page.icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap") %}{{ fd_icon(page.icon,14) }}{% elif page.icon and page.icon|length <= 2 %}{{ fd_icon("file",14) }}{% else %}{{ fd_icon("folder",14) }}{% endif %}</span>
<span class="page-icon">{{ fd_page_icon(page.icon, 14) }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
@@ -159,7 +159,7 @@
{% endif %}
{% endmacro %}
{% from '_icons.html' import fd_icon %}
{% from '_icons.html' import fd_icon, fd_page_icon %}
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
<aside class="sidebar"
@@ -662,6 +662,8 @@
</div>
</div>
{% include '_icon_picker.html' %}
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
<div class="context-menu" id="ws-context-menu"
:class="{ visible: wsCtx.visible }"
@@ -1099,10 +1101,7 @@
} else {
favList.innerHTML = items.map(function(item) {
var iconName = item.icon || 'file';
// Map known icon names, anything else (including emojis) → file
var validNames = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag'];
if (validNames.indexOf(iconName) === -1) iconName = 'file';
var icon = '<span class="page-icon-svg">'+getSvgIcon(iconName,14)+'</span>';
var icon = '<span class="page-icon-svg">' + (window.fdIconHtml ? window.fdIconHtml(iconName, 14) : getSvgIcon('file',14)) + '</span>';
const name = (item.title || 'Untitled');
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const url = item.url || ('/pages/' + item.id);
+3 -8
View File
@@ -446,7 +446,7 @@
</div>
</div>
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
<iframe id="lib-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
</div>
</div>
</template>
@@ -619,8 +619,8 @@ function libraryPage() {
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(item) {
if (!item) return getSvgIcon('file', 14);
// Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
if (item.page_icon) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(item.page_icon) + '</span>';
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
var n = (item.title || '').toLowerCase();
@@ -1141,7 +1141,6 @@ function libraryPage() {
async setItemIcon(item, icon) {
if (!item || !item.id) return;
icon = (icon || '').trim();
if (!icon) return;
try {
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
method: 'POST',
@@ -1375,14 +1374,10 @@ function libraryPage() {
async openPeek(item) {
this.peekItem = item;
this.peekFavorited = item.favorited || false;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
this.peekItem = null;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '';
},
async toggleFavoritePeek() {
+4 -2
View File
@@ -1098,7 +1098,6 @@ window._wsInitData = (function() {
async setItemIcon(node, icon) {
if (!node || !node.id) return;
icon = (icon || '').trim();
if (!icon) return;
try {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
@@ -1774,7 +1773,10 @@ window._wsInitData = (function() {
// ── File type icon ──
_fileIcon(name, isFolder, contentFormat, customIcon) {
if (customIcon) return '<span class="page-icon-svg" style="font-size:14px;line-height:1;">'+this.esc(customIcon)+'</span>';
if (customIcon) {
var html = window.fdIconHtml ? window.fdIconHtml(customIcon, 14) : this.esc(customIcon);
return '<span class="page-icon-svg" style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">'+html+'</span>';
}
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
// FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
+2 -1
View File
@@ -155,8 +155,9 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.5) 100%);"></div>
</div>
{% endif %}
{% from '_icons.html' import fd_page_icon %}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<span class="pub-icon" style="font-size:2rem;">{{ page_icon or fd_icon('file',24) }}</span>
<span class="pub-icon" style="font-size:2rem;">{{ fd_page_icon(page_icon, 32) if page_icon else fd_icon('file',24) }}</span>
<h1 class="pub-title">{{ title }}</h1>
</div>
{% if updated_at %}
+72 -1
View File
@@ -484,12 +484,27 @@ class TestCoverIcon:
page = _page(client, cookie, csrf, "Icon long")
r = client.post(
f"/board/api/pages/{page}/icon",
json={"icon": "x" * 32},
json={"icon": "x" * 600},
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 400
def test_custom_emoji_url_accepted_as_icon(self, client):
cookie, csrf = _login(client)
_workspace(client, cookie)
page = _page(client, cookie, csrf, "Custom icon")
url = "/api/files/1/emoji_123_cat.png"
r = client.post(
f"/board/api/pages/{page}/icon",
json={"icon": url},
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
assert r2.json()["page_icon"] == url
def test_public_page_renders_cover_and_icon(self, client):
cookie, csrf = _login(client)
_workspace(client, cookie)
@@ -543,3 +558,59 @@ class TestEditorEndpointPaths:
assert "fetch('/api/pages/'+this.pid+'/versions" not in tpl
assert "fetch('/api/pages/'+this.pid+'/backlinks" not in tpl
assert "fetch('/api/og/metadata" not in tpl
# ══════════════════════════════════════════════════════════════════════════
# v5.6.0: custom workspace emojis
# ══════════════════════════════════════════════════════════════════════════
class TestCustomEmojis:
def test_list_empty(self, client):
cookie, _ = _login(client)
_workspace(client, cookie)
r = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie})
assert r.status_code == 200
assert r.json()["emojis"] == []
def test_upload_and_delete(self, client):
cookie, csrf = _login(client)
_workspace(client, cookie)
png = b"\x89PNG\r\n\x1a\n" + b"0" * 16
r = client.post(
"/api/custom-emojis",
data={"name": "pushup"},
files={"file": ("pushup.png", png, "image/png")},
headers=_cookie_headers(cookie, csrf),
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 200
emoji = r.json()["emoji"]
assert emoji["name"] == "pushup"
assert emoji["url"].startswith("/api/files/")
listed = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie}).json()["emojis"]
assert any(e["id"] == emoji["id"] for e in listed)
d = client.request(
"DELETE",
f"/api/custom-emojis/{emoji['id']}",
headers=_cookie_headers(cookie, csrf),
cookies={"flowdeck_session": cookie},
)
assert d.status_code == 200
after = client.get("/api/custom-emojis", cookies={"flowdeck_session": cookie}).json()["emojis"]
assert all(e["id"] != emoji["id"] for e in after)
def test_reject_non_image(self, client):
cookie, csrf = _login(client)
_workspace(client, cookie)
r = client.post(
"/api/custom-emojis",
data={"name": "bad"},
files={"file": ("evil.sh", b"#!/bin/sh", "text/plain")},
headers=_cookie_headers(cookie, csrf),
cookies={"flowdeck_session": cookie},
)
assert r.status_code == 400