diff --git a/app/main.py b/app/main.py index a0978c4..e760445 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/migrations.py b/app/migrations.py index 21a934e..8663ee6 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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, diff --git a/app/routers/board.py b/app/routers/board.py index 5b345b1..7031395 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -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)) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 0211eb7..241685d 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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) diff --git a/app/routers/emoji.py b/app/routers/emoji.py new file mode 100644 index 0000000..af2dc05 --- /dev/null +++ b/app/routers/emoji.py @@ -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} diff --git a/app/templates/_ctx_menu.html b/app/templates/_ctx_menu.html index ac8ba1f..10ed9ca 100644 --- a/app/templates/_ctx_menu.html +++ b/app/templates/_ctx_menu.html @@ -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 ── #} - {# ── Edit icon ── #} -