Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d986959927 | ||
|
|
714642363b | ||
|
|
df78badd0c | ||
|
|
9836c85e24 | ||
|
|
61a33a7891 | ||
|
|
e707becbf8 | ||
|
|
da3e09c215 | ||
|
|
19e8ba0e4a | ||
|
|
7a6c66a609 | ||
|
|
3bb17eb984 | ||
|
|
bdc15c7328 | ||
|
|
c435e277f2 |
@@ -1,5 +1,36 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v5.11.2 (2026-09-12) — v5.5.0 Embeds & Média riche (complétion)
|
||||
|
||||
> Valide et complète le chantier v5.5.0 (parité embeds Notion). Une grande
|
||||
> partie était ébauchée ; ce patch répare les chemins d'API cassés côté éditeur,
|
||||
> finalise la résolution d'embed, le parsing OG, la lightbox publique et couvre
|
||||
> le tout de 47 tests dédiés.
|
||||
|
||||
- **Embed universel** — `app/services/embeds.py` réécrit : détection
|
||||
sous-domaine correcte, YouTube (watch/shorts/youtu.be), Vimeo, Figma,
|
||||
Google Maps, Google Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify,
|
||||
SoundCloud, Twitch (parent réel), X/Twitter, Pinterest, Microsoft Office ;
|
||||
helpers `resolve_embed()`, `inline_kind()`, `provider()`. Nouvel endpoint
|
||||
`POST /board/api/embed/resolve`.
|
||||
- **Résolution d'embed dans l'éditeur** — la saisie d'une URL appelle le
|
||||
résolveur et met en cache `embed_src`/`embed_provider` (l'URL d'origine est
|
||||
conservée) ; le rendu (éditeur, pages publiques, exports MD/HTML/PDF) préfère
|
||||
`embed_src`. Le champ est persisté via la sérialisation des blocs.
|
||||
- **Bookmark cards** — `app/services/og_fetcher.py` : parseur `<meta>` robuste
|
||||
(ordre d'attributs libre), titre de repli, favicon, transport injectable pour
|
||||
les tests ; `fetch_og_metadata` ne lève jamais sur erreur réseau.
|
||||
- **Image lightbox** — navigation clavier ←/→ et Escape, multi-images, dans
|
||||
l'éditeur **et** les pages publiques `/p/<slug>` (CSS `.fd-lightbox`).
|
||||
- **Fix chemins API (bug v5.4.0/v5.5.0)** — l'éditeur appelait
|
||||
`/api/pages/...` alors que les routes sont `/board/api/pages/...` : cover,
|
||||
icon, versions, backlinks, import, move et OG metadata étaient donc en 404.
|
||||
Tous corrigés, avec un test de régression statique.
|
||||
- **Tests** — `tests/test_v55.py` : 47 tests (embeds par provider, resolve API,
|
||||
parse OG + MockTransport, lightbox, préviews, cover upload/URL/suppression,
|
||||
icône, page publique). Suite complète **444 verte**, `ruff check` OK.
|
||||
- **Version** — 5.11.2.
|
||||
|
||||
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
|
||||
|
||||
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
|
||||
|
||||
+1
-1
@@ -41,4 +41,4 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/api/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
+86
-19
@@ -523,26 +523,90 @@ app/
|
||||
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
|
||||
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
|
||||
|
||||
### v5.5.0 — Embeds & Média Riche
|
||||
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
|
||||
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04).
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
|
||||
|
||||
- [ ] **Bloc Embed universel** — `/embed` : YouTube, Vimeo, Figma, Google Maps, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Office…
|
||||
- [ ] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description)
|
||||
- [ ] **Image lightbox** — clic pour agrandir, navigation clavier/consultation plein écran
|
||||
- [ ] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [ ] **Cover & icône de page** — upload image de couverture + emoji/icône custom (déjà partiel ?)
|
||||
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
|
||||
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
|
||||
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
|
||||
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
|
||||
|
||||
### v5.6.0 — Import de données (étendu)
|
||||
> **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types.
|
||||
Détails livrés :
|
||||
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
|
||||
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
|
||||
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
|
||||
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
|
||||
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
|
||||
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
|
||||
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
|
||||
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
|
||||
|
||||
- [ ] **Import CSV typé** — inférence automatique des types de propriétés (texte, nombre, date, select)
|
||||
- [ ] **Import Confluence / Evernote / Asana / Trello** — via leurs formats d'export (HTML/JSON)
|
||||
### v5.6.0 — Import de données (étendu) — Plan par phases
|
||||
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
|
||||
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
|
||||
> (Markdown/CSV/Notion, déjà dans v5.4.0). **Priorité n°1 de la roadmap.**
|
||||
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
|
||||
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
|
||||
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
|
||||
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
|
||||
|
||||
#### Phase 0 — Socle d'import unifié (prérequis)
|
||||
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
|
||||
- [ ] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [ ] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [ ] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [ ] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [ ] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [ ] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [ ] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [ ] **Tests** — harnais d'import + fixtures par source
|
||||
- [ ] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
|
||||
|
||||
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort
|
||||
- [ ] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [ ] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [ ] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [ ] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [ ] **Google Keep** — Takeout JSON + HTML
|
||||
- [ ] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen
|
||||
- [ ] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [ ] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [ ] **Google Sheets** — export CSV/XLSX
|
||||
- [ ] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
#### Phase 3 — Documents & bureautique — effort moyen
|
||||
- [ ] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [ ] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [ ] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [ ] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers
|
||||
- [ ] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [ ] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [ ] **Pocket** (CSV/HTML)
|
||||
- [ ] **Readwise** (highlights CSV/Markdown)
|
||||
- [ ] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [ ] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [ ] **OPML** (flux/outlines)
|
||||
- [ ] **Standard Notes / autres**
|
||||
|
||||
#### Priorisation
|
||||
| Ordre | Phase | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
|
||||
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
|
||||
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
|
||||
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
|
||||
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
|
||||
|
||||
- [ ] **Types propriété manquants** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`
|
||||
- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI
|
||||
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
|
||||
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
|
||||
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
|
||||
@@ -636,6 +700,9 @@ app/
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. **v5.6.0 → Import de données (5 phases)** ⬅ **priorité n°1** — Phase 0 socle unifié → Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) → Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) → Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) → Phase 3 documents (Word, Google Docs, HTML, PDF)
|
||||
9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -647,11 +714,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions blocks
|
||||
Collaboration Realtime, de bloc (undo/
|
||||
DB avancée, redo, drag&drop,
|
||||
Calendrier, AI duplicate)
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
|
||||
Collaboration Realtime, de bloc (undo/ (embed,
|
||||
DB avancée, redo, drag&drop, bookmark,
|
||||
Calendrier, AI duplicate) lightbox…)
|
||||
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.4.0 Expérience éditeur validé** (17 tests dédiés, 397 tests suite complète, ruff/eslint verts) ; reste v5.11 → v6.0*
|
||||
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; **plan d'import v5.6.0 détaillé en 5 phases** (socle unifié, notes/Markdown, données/tableaux, documents, signets/dev/divers) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
|
||||
|
||||
+4
-2
@@ -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
|
||||
@@ -85,7 +86,7 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v5.11.2 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -100,7 +101,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.11.1",
|
||||
version="5.11.2",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
-2
@@ -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))
|
||||
@@ -1384,6 +1384,26 @@ async def og_metadata(request: Request):
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
from app.config import settings
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
@@ -1021,6 +1021,92 @@ async def get_page_content(page_id: int):
|
||||
return {"content": row["content"] or "", "format": fmt}
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
@@ -1308,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)
|
||||
@@ -2230,7 +2318,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
)
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = embed_src(url) or url
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = b.get("height") or 520
|
||||
try:
|
||||
height = int(height)
|
||||
|
||||
@@ -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}
|
||||
+146
-62
@@ -1,9 +1,10 @@
|
||||
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
|
||||
|
||||
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom,
|
||||
CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office
|
||||
docs… exactly like Notion's universal embed.
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
|
||||
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
|
||||
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
|
||||
embed.
|
||||
|
||||
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
|
||||
fall back to a plain iframe so the link is still visible inline.
|
||||
@@ -11,11 +12,7 @@ fall back to a plain iframe so the link is still visible inline.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
_PROVIDERS = (
|
||||
# (host pattern, extra path check, to_embed(url, path, query) -> str | None)
|
||||
)
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
|
||||
def _q(params, key):
|
||||
@@ -23,9 +20,21 @@ def _q(params, key):
|
||||
return vals[0] if vals else ""
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(2) if m else _q(params, "v")
|
||||
def _host_matches(netloc: str, host: str) -> bool:
|
||||
"""True when ``netloc`` is ``host`` or one of its subdomains."""
|
||||
netloc = (netloc or "").lower().split(":")[0]
|
||||
host = host.lower()
|
||||
return netloc == host or netloc.endswith("." + host)
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(1) if m else _q(params, "v")
|
||||
if not vid:
|
||||
# youtu.be/<id> (short link) — the id is the first path segment.
|
||||
seg = path.strip("/").split("/")[0]
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
|
||||
vid = seg
|
||||
if not vid:
|
||||
return None
|
||||
start = _q(params, "t") or _q(params, "start")
|
||||
@@ -33,98 +42,122 @@ def _embed_youtube(url: str, path: str, params) -> str | None:
|
||||
return f"https://www.youtube.com/embed/{vid}{frag}"
|
||||
|
||||
|
||||
def _embed_vimeo(url: str, path: str, params) -> str | None:
|
||||
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(\d{6,12})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.vimeo.com/video/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_loom(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"/(embed/)?([0-9a-f]{32})", path)
|
||||
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://www.loom.com/embed/{m.group(2)}"
|
||||
return f"https://www.loom.com/embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_figma(url: str, path: str, params) -> str | None:
|
||||
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
|
||||
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
clean = url.split("?", 1)[0]
|
||||
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
|
||||
return None
|
||||
m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0])
|
||||
if not m:
|
||||
return None
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0]
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
|
||||
|
||||
|
||||
def _embed_map(url: str, path: str, params) -> str | None:
|
||||
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
|
||||
return None
|
||||
return f"https://www.google.com/maps?output=embed&q={url}"
|
||||
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params) -> str | None:
|
||||
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(
|
||||
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
kind, doc_id = m.group(1), m.group(2)
|
||||
if kind == "forms":
|
||||
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
|
||||
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
|
||||
|
||||
|
||||
def _embed_miro(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"miro\.com/app/board/([^/?#]+)", url)
|
||||
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://miro.com/app/live-embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_spotify(url: str, path: str, params) -> str | None:
|
||||
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_soundcloud(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url)
|
||||
if not m:
|
||||
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "soundcloud.com" not in url:
|
||||
return None
|
||||
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
|
||||
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
|
||||
|
||||
|
||||
def _embed_twitch(url: str, path: str, params) -> str | None:
|
||||
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitch.tv" not in url:
|
||||
return None
|
||||
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
|
||||
video = re.search(r"twitch\.tv/videos/(\d+)", url)
|
||||
if video:
|
||||
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
|
||||
m = re.search(r"twitch\.tv/([^/?#]+)", url)
|
||||
if not m:
|
||||
if not m or m.group(1) in ("videos", "directory"):
|
||||
return None
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
|
||||
|
||||
|
||||
def _embed_twitter(url: str, path: str, params) -> str | None:
|
||||
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitter.com" not in url and "x.com" not in url:
|
||||
return None
|
||||
m = re.search(r"/([^/?#]+)/status/(\d+)", url)
|
||||
m = re.search(r"/status(?:es)?/(\d+)", url)
|
||||
if not m:
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={url}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
|
||||
|
||||
|
||||
def _embed_pinterest(url: str, path: str, params) -> str | None:
|
||||
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "pinterest" not in url:
|
||||
return None
|
||||
return f"https://pinterest.com/pin/embed?url={url}"
|
||||
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params) -> str | None:
|
||||
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
low = url.lower().split("?", 1)[0]
|
||||
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
return None
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
|
||||
return url
|
||||
|
||||
|
||||
# (host, handler) — order matters: more specific hosts first.
|
||||
_HANDLERS = (
|
||||
("youtube.com", _embed_youtube),
|
||||
("youtu.be", _embed_youtube),
|
||||
("vimeo.com", _embed_vimeo),
|
||||
("loom.com", _embed_loom),
|
||||
("figma.com", _embed_figma),
|
||||
("docs.google.com", _embed_gdocs),
|
||||
("google.com/maps", _embed_map),
|
||||
("maps.app.goo.gl", _embed_map),
|
||||
("codepen.io", _embed_codepen),
|
||||
@@ -135,27 +168,54 @@ _HANDLERS = (
|
||||
("twitch.tv", _embed_twitch),
|
||||
("twitter.com", _embed_twitter),
|
||||
("x.com", _embed_twitter),
|
||||
("pinterest", _embed_pinterest),
|
||||
("pinterest.", _embed_pinterest),
|
||||
("office.com", _embed_office),
|
||||
("officeapps.live.com", _embed_office),
|
||||
("sharepoint.com", _embed_office),
|
||||
("1drv.ms", _embed_office),
|
||||
)
|
||||
|
||||
|
||||
def embed_src(url: str) -> str | None:
|
||||
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
|
||||
|
||||
|
||||
def _parse(url: str):
|
||||
raw = url.strip()
|
||||
if not raw:
|
||||
return None, None, None
|
||||
m = _SCHEME_RE.match(raw)
|
||||
if m:
|
||||
if m.group(1).lower() not in ("http", "https"):
|
||||
return None, None, None # mailto:, tel:, javascript:, data:…
|
||||
else:
|
||||
raw = "https://" + raw
|
||||
u = urlparse(raw)
|
||||
if u.scheme not in ("http", "https") or not u.netloc:
|
||||
return None, None, None
|
||||
host = u.hostname or ""
|
||||
if "." not in host and host != "localhost":
|
||||
return None, None, None # a bare word is not a URL
|
||||
return raw, u, parse_qs(u.query)
|
||||
|
||||
|
||||
def embed_src(url: str, *, parent: str = "") -> str | None:
|
||||
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
|
||||
if not url:
|
||||
raw, u, params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
url = url.strip()
|
||||
u = urlparse(url if url.startswith("http") else "https://" + url)
|
||||
if u.scheme not in ("http", "https"):
|
||||
return None
|
||||
(u.hostname or "").lower().replace("www.", "")
|
||||
ctx = {"parent": parent}
|
||||
netloc = (u.netloc or "").lower()
|
||||
params = parse_qs(u.query)
|
||||
# Google Maps share links encode the query in the path (…&q=/maps/…)
|
||||
encoded = url
|
||||
for needle, handler in _HANDLERS:
|
||||
if needle in netloc:
|
||||
return handler(encoded, u.path, params)
|
||||
return _embed_files(encoded, u.path, params)
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return handler(raw, u.path, params, ctx)
|
||||
elif _host_matches(netloc, needle):
|
||||
return handler(raw, u.path, params, ctx)
|
||||
# Office documents hosted on arbitrary domains.
|
||||
office = _embed_office(raw, u.path, params, ctx)
|
||||
if office:
|
||||
return office
|
||||
return _embed_files(raw, u.path, params, ctx)
|
||||
|
||||
|
||||
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
|
||||
@@ -165,12 +225,12 @@ _AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
|
||||
|
||||
|
||||
def inline_kind(url: str) -> str | None:
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' |
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
|
||||
| 'audio'. Returns None when the URL should open in a new tab."""
|
||||
if not url:
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
url = url.strip()
|
||||
path = urlparse(url).path
|
||||
path = u.path or ""
|
||||
if _IMAGE_EXT.search(path):
|
||||
return "image"
|
||||
if _PDF_EXT.search(path):
|
||||
@@ -179,9 +239,33 @@ def inline_kind(url: str) -> str | None:
|
||||
return "video"
|
||||
if _AUDIO_EXT.search(path):
|
||||
return "audio"
|
||||
if url.startswith(("http://", "https://")):
|
||||
return "iframe"
|
||||
return None
|
||||
return "iframe"
|
||||
|
||||
|
||||
def provider(url: str) -> str:
|
||||
"""Human-readable provider name for a URL (used by the editor)."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return ""
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, _handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return needle.split(".")[0].rstrip(".")
|
||||
elif _host_matches(netloc, needle):
|
||||
name = needle.split(".")[0]
|
||||
return "youtube" if name == "youtu" else name
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_embed(url: str, *, parent: str = "") -> dict:
|
||||
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
|
||||
kind = inline_kind(url)
|
||||
return {
|
||||
"src": embed_src(url, parent=parent) or "",
|
||||
"kind": kind or "",
|
||||
"provider": provider(url),
|
||||
}
|
||||
|
||||
|
||||
def embed_html(src: str, *, height: int = 520) -> str:
|
||||
|
||||
@@ -665,7 +665,7 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = embed_src(url) or url
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
|
||||
+74
-56
@@ -7,39 +7,44 @@ slow/unreachable hosts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_RE = re.compile(
|
||||
r'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
|
||||
re.I,
|
||||
)
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
||||
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", re.I)
|
||||
_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I)
|
||||
|
||||
# Property/name keys we look for, in priority order, mapped to our payload keys.
|
||||
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
|
||||
_OG_DESC = ("og:description", "twitter:description", "description")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image")
|
||||
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
|
||||
|
||||
|
||||
def _extract_og(html: str) -> dict:
|
||||
text = html[:400_000] # only scan the beginning — that's where <head> lives
|
||||
def _attrs(tag: str) -> dict:
|
||||
return {k.lower(): v for k, v in _ATTR_RE.findall(tag)}
|
||||
|
||||
|
||||
def _extract_og(body: str) -> dict:
|
||||
"""Parse all ``<meta>`` tags into a ``{key: content}`` dict.
|
||||
|
||||
Attributes may appear in any order (``content`` before or after
|
||||
``property``/``name``), which the previous implementation mishandled.
|
||||
First value wins so the most specific tag (top of document) is kept.
|
||||
"""
|
||||
props: dict[str, str] = {}
|
||||
for m in _META_RE.finditer(text):
|
||||
m.groups()
|
||||
content = ""
|
||||
prop = ""
|
||||
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
|
||||
if prop == "":
|
||||
prop = s
|
||||
else:
|
||||
content = s
|
||||
break
|
||||
if prop:
|
||||
props[prop.lower()] = content
|
||||
for tag in _META_TAG_RE.finditer(body[:400_000]):
|
||||
attrs = _attrs(tag.group(0))
|
||||
key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower()
|
||||
content = attrs.get("content")
|
||||
if key and content is not None and key not in props:
|
||||
props[key] = content
|
||||
return props
|
||||
|
||||
|
||||
@@ -51,12 +56,12 @@ def _pick(props: dict, keys: tuple) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _title_of(props: dict, raw_title: str) -> str:
|
||||
def _title_of(props: dict, body: str) -> str:
|
||||
t = _pick(props, _OG_TITLE)
|
||||
if t:
|
||||
return t
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", raw_title[:200_000], re.I | re.S)
|
||||
return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc
|
||||
m = _TITLE_RE.search(body[:200_000])
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _site_name(url: str) -> str:
|
||||
@@ -64,20 +69,62 @@ def _site_name(url: str) -> str:
|
||||
return host.split(".")[0].capitalize() if host else ""
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
|
||||
def _favicon(body: str, base_url: str) -> str:
|
||||
for tag in _FAVICON_RE.finditer(body):
|
||||
attrs = _attrs(tag.group(0))
|
||||
rel = attrs.get("rel", "")
|
||||
href = attrs.get("href", "")
|
||||
if href and _ICON_REL.search(rel):
|
||||
return urljoin(base_url, htmlmod.unescape(href))
|
||||
return ""
|
||||
|
||||
|
||||
def parse_og(body: str, url: str) -> dict:
|
||||
"""Pure HTML → bookmark payload (no network). ``url`` is the base URL."""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
props = _extract_og(body)
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
return urljoin(src, htmlmod.unescape(u)) if u else ""
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": _favicon(body, src),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors."""
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
|
||||
``transport`` is an optional ``httpx`` transport (used by tests to mock
|
||||
HTTP without hitting the network).
|
||||
"""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
@@ -92,33 +139,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
import html as htmlmod
|
||||
|
||||
body = resp.text
|
||||
props = _extract_og(body)
|
||||
props["_url"] = src
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
if not u:
|
||||
return ""
|
||||
return urljoin(src, htmlmod.unescape(u))
|
||||
|
||||
favicon = ""
|
||||
fm = re.search(r"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
|
||||
if not fm:
|
||||
fm = re.search(r"<link[^>]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I)
|
||||
if fm:
|
||||
favicon = abs_url(fm.group(1))
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": favicon,
|
||||
}
|
||||
return parse_og(resp.text, src)
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
avec la capacité respective de la page au clic.
|
||||
|
||||
Handlers attendus (tous optionnels) :
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, move,
|
||||
fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
@@ -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 ── #}
|
||||
@@ -224,6 +254,18 @@
|
||||
<span style="flex:1;">Copy link</span>
|
||||
</div>
|
||||
|
||||
{# ── Download file (file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span style="flex:1;">Download</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy file content (textual file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Copy content</span>
|
||||
</div>
|
||||
|
||||
{# ── Move to ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
<header class="topbar unified-header">
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
@@ -51,7 +52,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">
|
||||
@@ -141,7 +142,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
@@ -247,7 +248,11 @@ document.addEventListener('alpine:init', function () {
|
||||
this.activeItems = [];
|
||||
},
|
||||
|
||||
go: function (url) { if (url) window.location.href = url; }
|
||||
go: function (url) {
|
||||
if (!url) return;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '"') + '" 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>
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -73,6 +73,9 @@ document.addEventListener('alpine:init', function () {
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
destroy() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<template x-for="ic in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ic">
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
@@ -457,6 +457,17 @@
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="contentFormat === 'file' && fileUrl">
|
||||
<div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="downloadFile()">
|
||||
{{ fd_icon("download",14) }} Download
|
||||
</div>
|
||||
<div class="more-menu-item" @click="copyFileContent()">
|
||||
{{ fd_icon("copy",14) }} Copy content
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
@@ -497,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"
|
||||
@@ -804,10 +815,12 @@
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<div x-show="importFile" style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
@@ -37,6 +37,19 @@ window.__fdRT = (function () {
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
@@ -459,9 +472,14 @@ window.__fdRT = (function () {
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
|
||||
window.addEventListener('scroll', scheduleDraw, true);
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</script>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
if (!window.__fdEditorScriptsLoaded) {
|
||||
window.__fdEditorScriptsLoaded = true;
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
@@ -439,8 +441,7 @@
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
var url=b.src||'';if(url){
|
||||
var src=url;
|
||||
try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){}
|
||||
var src=b.embed_src||url;
|
||||
var height=b.height||520;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
|
||||
}
|
||||
@@ -782,6 +783,9 @@
|
||||
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
|
||||
if(act==='duplicate'){E.duplicateBlock(idx);}
|
||||
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
|
||||
else if(act==='copy-blocks'){E.copySelectedBlocks();}
|
||||
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
|
||||
else if(act==='paste-blocks'){E.pasteBlocks();}
|
||||
else if(act==='move-to'){_blockMoveToOpen(idx);}
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
@@ -801,6 +805,11 @@
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
@@ -892,6 +901,8 @@
|
||||
undoStack:[],redoStack:[],_histCap:100,_histLock:false,
|
||||
// ── Cover & icon (v5.10+) ──
|
||||
coverUrl:'',
|
||||
coverOpen:false,
|
||||
coverLoading:false,
|
||||
iconOpen:false,
|
||||
iconValue:'',
|
||||
// ── Import ──
|
||||
@@ -920,7 +931,7 @@
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
|
||||
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
|
||||
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
@@ -944,6 +955,7 @@
|
||||
},100);
|
||||
},
|
||||
fileData:null,
|
||||
fileUrl:'',
|
||||
async loadFileContent(data){
|
||||
const url=data.file_url||'';const mime=data.file_mime||'';const name=data.file_name||'';const self=this;
|
||||
if(!url){this.blocks=[this.mkB('paragraph','File not available')];this.render();return;}
|
||||
@@ -1036,7 +1048,20 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
},
|
||||
setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');},
|
||||
async setEmbedUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
const cur=b.src||''; const u=prompt('Embed URL (YouTube, Vimeo, Figma, Maps…):', cur);
|
||||
if(u===null)return;
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b.src=u.trim();b.embed_type='';this.dirty=true;
|
||||
try{
|
||||
const r=await fetch('/board/api/embed/resolve',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url:u.trim()})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok&&d.src){b.embed_src=d.src;b.embed_provider=d.provider||'';}
|
||||
}catch(e){}
|
||||
this.render();this.autoSave();_rtSync();
|
||||
},
|
||||
setBookmarkUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
@@ -1048,7 +1073,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
},
|
||||
async _fetchBookmarkPreview(bid, url){
|
||||
try{
|
||||
const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const r=await fetch('/board/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
@@ -1064,17 +1089,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
|
||||
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
|
||||
|
||||
// ── v5.5.0: Image lightbox ──
|
||||
// ── v5.5.0: Image lightbox (fullscreen + keyboard navigation) ──
|
||||
_openLightbox(imgEl){
|
||||
const full=imgEl.dataset.full||imgEl.src;
|
||||
if(!full)return;
|
||||
const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
ov.innerHTML='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();};
|
||||
const imgs=Array.from(document.querySelectorAll('.block-image img, .fd-img')).filter(i=>i.dataset.full||i.src);
|
||||
let idx=imgs.indexOf(imgEl);
|
||||
if(idx<0)idx=0;
|
||||
const overlay=document.createElement('div');overlay.className='fd-lightbox';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
overlay.innerHTML='<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+'<img class="fd-lightbox-img" style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+'<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+'<div class="fd-lightbox-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(overlay);
|
||||
const img=overlay.querySelector('.fd-lightbox-img');
|
||||
const hint=overlay.querySelector('.fd-lightbox-hint');
|
||||
const show=()=>{const cur=imgs[idx];img.src=cur.dataset.full||cur.src;hint.textContent=(imgs.length>1?(idx+1)+' / '+imgs.length+' — ':'')+'← → to navigate · Esc to close';};
|
||||
const step=(d)=>{if(imgs.length<2)return;idx=(idx+d+imgs.length)%imgs.length;show();};
|
||||
show();
|
||||
const close=()=>{overlay.remove();document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();else if(e.key==='ArrowRight')step(1);else if(e.key==='ArrowLeft')step(-1);};
|
||||
document.addEventListener('keydown',kd);
|
||||
ov.addEventListener('click',e=>{if(e.target===ov)close();});
|
||||
overlay.querySelectorAll('[data-nav]').forEach(btn=>btn.addEventListener('click',e=>{e.stopPropagation();step(btn.dataset.nav==='next'?1:-1);}));
|
||||
overlay.addEventListener('click',e=>{if(e.target===overlay)close();});
|
||||
},
|
||||
|
||||
// ── v5.4.0: Version history UI ──
|
||||
@@ -1086,7 +1123,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadVersions(){
|
||||
this.versionsLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions');
|
||||
const d=await r.json();
|
||||
this.versionsList=d.versions||[];
|
||||
}catch(e){this.versionsList=[];}
|
||||
@@ -1095,7 +1132,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async restoreVersion(vid){
|
||||
if(!confirm('Restore this version? Current content will be replaced.'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){
|
||||
this.showToast('Version restored');this.versionsOpen=false;
|
||||
@@ -1113,7 +1150,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadBacklinks(){
|
||||
this.backlinksLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/backlinks');
|
||||
const d=await r.json();
|
||||
this.backlinksList=d.backlinks||[];
|
||||
}catch(e){this.backlinksList=[];}
|
||||
@@ -1135,7 +1172,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u===null)return;
|
||||
this.coverLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');}
|
||||
}catch(e){this.showToast('Cover update failed','error');}
|
||||
@@ -1146,7 +1183,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
this.coverLoading=true;
|
||||
const fd=new FormData();fd.append('file',f);
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');}
|
||||
}catch(e){this.showToast('Cover upload failed','error');}
|
||||
@@ -1156,18 +1193,31 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async removeCover(){
|
||||
if(!confirm('Remove cover?'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
|
||||
}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('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
|
||||
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');}
|
||||
},
|
||||
|
||||
@@ -1186,13 +1236,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(this.importFile){
|
||||
const fd=new FormData();fd.append('file',this.importFile);
|
||||
try{
|
||||
const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
}else{
|
||||
try{
|
||||
const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const r=await fetch('/board/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
@@ -1221,6 +1271,12 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
|
||||
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
// ── v5.11: Copy / Cut / Paste selected blocks ──
|
||||
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
|
||||
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
}catch(e){this.showToast('Échec du déplacement','error');}},
|
||||
@@ -1518,7 +1574,7 @@ applyAIBlocks(text){
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
fetch('/board/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
@@ -1534,6 +1590,8 @@ applyAIBlocks(text){
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
// ── v4.6.0 / v5.3.0: Get Started actions — Database (template picker) ──
|
||||
dbTplOpen:false, dbTplLoading:false, dbTemplates:[], dbTplTargetIdx:-1,
|
||||
@@ -1621,7 +1679,7 @@ applyAIBlocks(text){
|
||||
}
|
||||
},
|
||||
|
||||
onKd(e){
|
||||
async onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
@@ -1666,6 +1724,20 @@ applyAIBlocks(text){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
|
||||
if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;}
|
||||
if(e.key.toLowerCase()==='c'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.copySelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='x'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.cutSelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='v'){
|
||||
e.preventDefault();
|
||||
await this.pasteBlocks();return;
|
||||
}
|
||||
const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
@@ -1941,7 +2013,7 @@ applyAIBlocks(text){
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
@@ -2052,4 +2124,6 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
window.editorState = editorState;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
</style>
|
||||
|
||||
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
(function(){
|
||||
var defaultAPI = {
|
||||
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
||||
@@ -293,7 +293,7 @@
|
||||
<div class="fd-steps" x-show="m.steps && m.steps.length" x-cloak>
|
||||
<button class="fd-steps-head" type="button" @click="toggleSteps(m)">
|
||||
<span class="fd-steps-chev" :class="{down: m.stepsOpen}">▶</span>
|
||||
<span class="fd-steps-count" x-text="m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes'"></span>
|
||||
<span class="fd-steps-count" x-text="!m.steps ? '' : (m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes')"></span>
|
||||
<span class="fd-steps-elem" x-text="m.generating ? 'en cours…' : (m.stepsOpen ? 'réduire' : 'voir le déroulé')"></span>
|
||||
</button>
|
||||
<div class="fd-steps-body" x-show="m.stepsOpen" x-cloak>
|
||||
@@ -373,18 +373,20 @@
|
||||
<template x-if="!mentionLoad && !hasMentionItems">
|
||||
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
|
||||
</template>
|
||||
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
|
||||
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
<template x-if="!mentionLoad">
|
||||
<template x-for="it in mentionItems" :key="it.token || it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
|
||||
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,7 +456,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
(function(){
|
||||
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
|
||||
var FD_ADMIN_CMDS = [
|
||||
|
||||
+134
-26
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
@@ -45,6 +45,15 @@
|
||||
.page-title-block {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.page-title-block::before {
|
||||
display: none;
|
||||
}
|
||||
.page-title-block .page-icon-emoji {
|
||||
margin-left: 0;
|
||||
}
|
||||
.page-title-block .page-title-input {
|
||||
padding-left: 0;
|
||||
}
|
||||
.blocks-container {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -99,9 +108,9 @@
|
||||
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
|
||||
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
||||
</style>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
@@ -109,9 +118,14 @@
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
:class="{ visible: mobileSidebarOpen }"
|
||||
@click="mobileSidebarOpen = false, sidebarCollapsed = true">
|
||||
@click="mobileSidebarOpen = false">
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ SIDEBAR PEEK ZONE (collapsed, desktop) ═══════════ -->
|
||||
<div class="sidebar-peek-zone"
|
||||
x-show="sidebarCollapsed" x-cloak
|
||||
@mouseenter="sidebarPeek = true"></div>
|
||||
|
||||
{# ─── Recursive tree macro ─── #}
|
||||
{% macro render_tree_item(page, depth=0) %}
|
||||
{% set indent_px = depth * 16 %}
|
||||
@@ -133,7 +147,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>
|
||||
@@ -150,12 +164,14 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
{% from '_icons.html' import fd_icon, fd_page_icon %}
|
||||
|
||||
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
|
||||
<aside class="sidebar"
|
||||
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
@@ -171,8 +187,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
|
||||
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
<button class="sidebar-collapse-inline" @click.stop="toggleSidebar()"
|
||||
:title="sidebarPeek ? 'Pin sidebar open' : 'Close sidebar'">
|
||||
<svg x-show="!sidebarPeek" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
<svg x-show="sidebarPeek" x-cloak width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-menu-dropdown" x-show="workspaceMenuOpen" @click.outside="workspaceMenuOpen = false" x-transition>
|
||||
@@ -202,7 +220,7 @@
|
||||
<div class="um-sep"></div>
|
||||
<div class="um-version">FlowDeck v{{ app_version }}</div>
|
||||
<div class="um-sep"></div>
|
||||
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +274,7 @@
|
||||
{% else %}
|
||||
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
|
||||
{% endif %}
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -573,8 +591,29 @@
|
||||
|
||||
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
|
||||
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
|
||||
|
||||
<!-- Right-edge resize handle: click to close, drag to resize -->
|
||||
<div class="sidebar-resize-handle"
|
||||
@mouseenter="sidebarResizeHover = true"
|
||||
@mouseleave="sidebarResizeHover = false"
|
||||
@pointerdown.prevent="startSidebarResize($event)"
|
||||
title=""></div>
|
||||
</aside>
|
||||
|
||||
<!-- Resize / close contextual tooltip -->
|
||||
<div class="sidebar-resize-tip"
|
||||
x-show="sidebarResizeHover && (!sidebarCollapsed || sidebarPeek)" x-cloak
|
||||
:style="'left:' + (sidebarWidth + 12) + 'px'">
|
||||
<div class="srt-row">
|
||||
<span class="srt-key">Close</span>
|
||||
<span class="srt-action">Click · <span class="srt-kbd">Ctrl+\</span></span>
|
||||
</div>
|
||||
<div class="srt-row">
|
||||
<span class="srt-key">Resize</span>
|
||||
<span class="srt-action">Drag ↔</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uncollapse button → inside topbar, no overlap -->
|
||||
<!-- (Moved into topbar block below) -->
|
||||
|
||||
@@ -589,6 +628,7 @@
|
||||
<!-- Content -->
|
||||
<div class="content-area" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -653,6 +693,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include '_icon_picker.html' %}
|
||||
|
||||
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
|
||||
<div class="context-menu" id="ws-context-menu"
|
||||
:class="{ visible: wsCtx.visible }"
|
||||
@@ -951,6 +993,8 @@
|
||||
window.appState = this;
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
},
|
||||
agentList: [],
|
||||
loadAgents() {
|
||||
@@ -968,7 +1012,14 @@
|
||||
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
sidebarPeek: false,
|
||||
mobileSidebarOpen: false,
|
||||
sidebarWidth: 240,
|
||||
sidebarResizing: false,
|
||||
sidebarResizeHover: false,
|
||||
sidebarResizeMoved: false,
|
||||
sidebarResizeStartX: 0,
|
||||
sidebarResizeStartW: 0,
|
||||
showInvite: true,
|
||||
// -- Toast notifications --
|
||||
toasts: [],
|
||||
@@ -1090,10 +1141,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, '"');
|
||||
const url = item.url || ('/pages/' + item.id);
|
||||
@@ -1247,11 +1295,6 @@
|
||||
toggleWorkspaceMenu() {
|
||||
this.workspaceMenuOpen = !this.workspaceMenuOpen;
|
||||
},
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
||||
|
||||
// ── Tree state ──
|
||||
@@ -1320,12 +1363,76 @@
|
||||
},
|
||||
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarPeek = false;
|
||||
// Preserve workspace context when navigating to library/trash
|
||||
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
|
||||
const parts = this.workspaceKey.split('/');
|
||||
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
closeSidebar() {
|
||||
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
||||
this.sidebarCollapsed = true;
|
||||
},
|
||||
|
||||
// ── Sidebar width: persisted, applies to fixed + floating ──
|
||||
initSidebarWidth() {
|
||||
var saved = parseInt(localStorage.getItem('fd.sidebarWidth') || '', 10);
|
||||
if (!isNaN(saved) && saved >= 200 && saved <= 560) {
|
||||
this.sidebarWidth = saved;
|
||||
this.applySidebarWidth();
|
||||
} else {
|
||||
var el = document.getElementById('sidebar');
|
||||
if (el) {
|
||||
var w = el.getBoundingClientRect().width;
|
||||
if (w > 0) { this.sidebarWidth = Math.round(w); }
|
||||
}
|
||||
}
|
||||
},
|
||||
applySidebarWidth() {
|
||||
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
|
||||
},
|
||||
startSidebarResize(e) {
|
||||
e.preventDefault();
|
||||
var self = this;
|
||||
this.sidebarResizing = true;
|
||||
this.sidebarResizeMoved = false;
|
||||
this.sidebarResizeStartX = e.clientX;
|
||||
var el = document.getElementById('sidebar');
|
||||
this.sidebarResizeStartW = el ? el.getBoundingClientRect().width : this.sidebarWidth;
|
||||
document.body.classList.add('fd-sidebar-resizing');
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - self.sidebarResizeStartX;
|
||||
if (Math.abs(dx) > 3) { self.sidebarResizeMoved = true; }
|
||||
var w = Math.max(200, Math.min(560, self.sidebarResizeStartW + dx));
|
||||
self.sidebarWidth = w;
|
||||
self.applySidebarWidth();
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
document.body.classList.remove('fd-sidebar-resizing');
|
||||
self.sidebarResizing = false;
|
||||
if (self.sidebarResizeMoved) {
|
||||
try { localStorage.setItem('fd.sidebarWidth', String(Math.round(self.sidebarWidth))); } catch (err) {}
|
||||
} else {
|
||||
self.closeSidebar();
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
document.addEventListener('pointercancel', onUp);
|
||||
},
|
||||
|
||||
showContextMenu(event, pageId, pageName) {
|
||||
@@ -1661,7 +1768,8 @@
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
|
||||
else window.location = `/pages/${pageId}`;
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
@@ -1681,7 +1789,8 @@
|
||||
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
|
||||
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
|
||||
} catch(e) {}
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
|
||||
else window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
createEmptyPage() {
|
||||
@@ -1833,8 +1942,7 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v=2.4.6" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
|
||||
|
||||
<style>
|
||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||
@@ -2026,7 +2134,7 @@
|
||||
function choose(i){
|
||||
var it = state.items[i]; if(!it) return;
|
||||
if(it.run){ var ok = it.run(); if(ok) close(); return; }
|
||||
if(it.url){ window.location.href=it.url; close(); return; }
|
||||
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
|
||||
}
|
||||
|
||||
function onKey(e){
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
+37
-10
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_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();
|
||||
@@ -718,6 +718,8 @@ function libraryPage() {
|
||||
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self.openMovePicker([n.id]); },
|
||||
fav: function (n) { self.toggleFavoriteItem(n); },
|
||||
delete: function (n) { self.deleteItem(n); },
|
||||
@@ -731,6 +733,34 @@ function libraryPage() {
|
||||
store.openMenu(evt, item, 'library', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Drag & drop (move / reparent) ──
|
||||
onRowDragStart(evt, id) {
|
||||
if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; }
|
||||
@@ -1111,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',
|
||||
@@ -1274,7 +1303,8 @@ function libraryPage() {
|
||||
// ── Item actions ──
|
||||
openItem(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.location.href = '/pages/' + item.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
|
||||
else window.location.href = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
startRename(item) {
|
||||
@@ -1332,7 +1362,8 @@ function libraryPage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/' + d.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
@@ -1345,14 +1376,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() {
|
||||
|
||||
@@ -867,7 +867,7 @@ window._wsInitData = (function() {
|
||||
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
document.getElementById('ws-preview-title').textContent = node.name;
|
||||
var openBtn = document.getElementById('ws-preview-open-btn');
|
||||
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
||||
openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else window.location = '/pages/' + node.id; };
|
||||
var favBtn = document.getElementById('ws-preview-fav-btn');
|
||||
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
|
||||
var copyBtn = document.getElementById('ws-preview-copy-btn');
|
||||
@@ -1025,6 +1025,8 @@ window._wsInitData = (function() {
|
||||
setIcon: function (icon) { self.setItemIcon(node, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyItemLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self._ctxMove(n); },
|
||||
fav: function (n) { self.toggleFavorite(n); },
|
||||
delete: function (n) { self.deleteWithUndo(n); },
|
||||
@@ -1035,6 +1037,34 @@ window._wsInitData = (function() {
|
||||
store.openMenu(ev, node, 'workspace', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_ctxAvailTags(node) {
|
||||
return (this.workspaceTags || []).filter(function(t) {
|
||||
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||||
@@ -1068,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',
|
||||
@@ -1219,7 +1248,7 @@ window._wsInitData = (function() {
|
||||
} else {
|
||||
// Click to rename, double-click to open
|
||||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||||
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' ondblclick="window.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' title="Click to rename, double-click to open"'
|
||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
||||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
|
||||
@@ -1611,11 +1640,12 @@ window._wsInitData = (function() {
|
||||
navigateToPath() {
|
||||
var path = (this.$el.querySelector('.path-input') || {}).value || '';
|
||||
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
|
||||
if (parts.length === 0) { window.location = '/local-workspace'; return; }
|
||||
if (parts.length === 0) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
|
||||
// Walk tree to find matching folder
|
||||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||||
if (targetId) {
|
||||
window.location = '/local-workspace?folder=' + targetId;
|
||||
if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
|
||||
else window.location = '/local-workspace?folder=' + targetId;
|
||||
} else {
|
||||
window.showToast('Path not found: ' + parts.join('/'), 'error');
|
||||
}
|
||||
@@ -1744,7 +1774,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>';
|
||||
@@ -1931,7 +1964,7 @@ window._wsInitData = (function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
openPage(id) { window.location = '/pages/'+id; },
|
||||
openPage(id) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else window.location = '/pages/' + id; },
|
||||
openSidePeek(id) {
|
||||
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
|
||||
if (node) this.selectForPreview(node);
|
||||
@@ -2462,7 +2495,6 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
|
||||
<!-- Breadcrumb + toolbar row (Library-style) -->
|
||||
<div class="breadcrumb-row">
|
||||
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
|
||||
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
|
||||
|
||||
@@ -125,6 +125,18 @@
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pub-content img[data-full] { cursor: zoom-in; }
|
||||
.fd-lightbox { user-select: none; }
|
||||
.fd-lightbox-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 48px; height: 48px; border-radius: 50%; border: none;
|
||||
background: rgba(255,255,255,.12); color: #fff; font-size: 30px;
|
||||
line-height: 1; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; transition: background .15s ease;
|
||||
}
|
||||
.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); }
|
||||
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
|
||||
.fd-lightbox-nav[data-nav="next"] { right: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -143,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 %}
|
||||
@@ -162,15 +175,48 @@
|
||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!window.katex) return;
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
if (window.katex) {
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// v5.5.0 — Image lightbox (fullscreen + keyboard navigation)
|
||||
var imgs = Array.prototype.slice.call(document.querySelectorAll('.pub-content img[data-full]'));
|
||||
imgs.forEach(function (im) { im.addEventListener('click', function () { openLb(im); }); });
|
||||
|
||||
function openLb(target) {
|
||||
var idx = imgs.indexOf(target); if (idx < 0) idx = 0;
|
||||
var ov = document.createElement('div');
|
||||
ov.className = 'fd-lightbox';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
ov.innerHTML = '<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+ '<img style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+ '<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+ '<div class="fd-lb-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(ov);
|
||||
var img = ov.querySelector('img');
|
||||
var hint = ov.querySelector('.fd-lb-hint');
|
||||
function show() {
|
||||
var cur = imgs[idx];
|
||||
img.src = cur.getAttribute('data-full') || cur.src;
|
||||
hint.textContent = (imgs.length > 1 ? (idx + 1) + ' / ' + imgs.length + ' — ' : '') + '← → to navigate · Esc to close';
|
||||
}
|
||||
});
|
||||
function step(d) { if (imgs.length < 2) return; idx = (idx + d + imgs.length) % imgs.length; show(); }
|
||||
function close() { ov.remove(); document.removeEventListener('keydown', kd); }
|
||||
function kd(e) { if (e.key === 'Escape') close(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); }
|
||||
document.addEventListener('keydown', kd);
|
||||
ov.querySelectorAll('[data-nav]').forEach(function (b) {
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); step(b.getAttribute('data-nav') === 'next' ? 1 : -1); });
|
||||
});
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
|
||||
@@ -142,6 +142,7 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
SIDEBAR
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
.sidebar {
|
||||
position: relative;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
@@ -151,6 +152,7 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-scroll {
|
||||
flex: 1;
|
||||
@@ -159,11 +161,110 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ── Hidden state: flush collapse, no separator ── */
|
||||
.sidebar.collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ── Notion-style collapsed peek (hover the left edge) ──
|
||||
Floating card: inset from the screen, rounded corners, right shadow. */
|
||||
.sidebar-peek-zone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
.sidebar.peeking {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 0;
|
||||
bottom: 18px;
|
||||
height: auto;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
max-width: calc(100vw - 16px);
|
||||
z-index: 1001;
|
||||
border-right: none;
|
||||
border-radius: 14px 20px 20px 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 8px 0 26px rgba(0, 0, 0, .16), 0 4px 16px rgba(0, 0, 0, .10);
|
||||
animation: fd-sidebar-peek-in .16s ease-out;
|
||||
}
|
||||
|
||||
/* ── Right-edge resize handle (fixed + floating) ── */
|
||||
.sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
z-index: 40;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.sidebar-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 2px;
|
||||
width: 2px;
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
transition: background .12s ease;
|
||||
}
|
||||
.sidebar-resize-handle:hover::before,
|
||||
body.fd-sidebar-resizing .sidebar-resize-handle::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
body.fd-sidebar-resizing { cursor: col-resize !important; user-select: none; }
|
||||
body.fd-sidebar-resizing * { cursor: col-resize !important; }
|
||||
|
||||
/* ── Resize / close contextual tooltip ── */
|
||||
.sidebar-resize-tip {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1200;
|
||||
min-width: 200px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-modal);
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.srt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.srt-key { color: var(--text); font-weight: 600; }
|
||||
.srt-action { color: var(--text-dim); white-space: nowrap; }
|
||||
.srt-kbd {
|
||||
display: inline-block;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Statusbar follows the hidden sidebar */
|
||||
html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
@keyframes fd-sidebar-peek-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Workspace header — Notion style */
|
||||
@@ -194,6 +295,10 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
.sidebar-workspace-header.hover .sidebar-collapse-inline {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Floating state: the pin (>>) stays visible so it is easy to lock open */
|
||||
.sidebar.peeking .sidebar-collapse-inline {
|
||||
opacity: 1;
|
||||
}
|
||||
.sidebar-collapse-inline:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
@@ -2213,6 +2318,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-icon-emoji {
|
||||
@@ -2296,6 +2402,24 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
border-radius: 3px;
|
||||
fill: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Align page title content with blocks (accounting for drag handle offset) */
|
||||
.page-title-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.page-title-block .page-icon-emoji {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.page-title-block .page-title-input {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.block-handle.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -3511,6 +3635,12 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
}
|
||||
.hamburger-btn:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Show the hamburger on desktop only when the sidebar is collapsed,
|
||||
so it can be reopened from the top-left corner. */
|
||||
@media (min-width: 769px) {
|
||||
.hamburger-btn.hamburger-desktop-show { display: flex; }
|
||||
}
|
||||
|
||||
/* ── Mobile Sidebar Overlay ── */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
@@ -3550,6 +3680,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-cover-area { padding: 32px 32px 8px; }
|
||||
.blocks-container { padding: 8px 32px 80px; }
|
||||
.block-handle { left: -32px; }
|
||||
.page-title-block::before { left: -32px; }
|
||||
.page-title-input { font-size: 32px; }
|
||||
|
||||
.kanban-column { min-width: 240px; max-width: 300px; }
|
||||
@@ -3591,6 +3722,18 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
}
|
||||
.sidebar-back-btn { display: flex; }
|
||||
.sidebar-uncollapse-btn { display: none !important; }
|
||||
.sidebar-peek-zone { display: none; }
|
||||
.sidebar-resize-handle { display: none; }
|
||||
.sidebar.peeking {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
width: 85vw;
|
||||
max-width: 320px;
|
||||
min-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
@@ -3629,6 +3772,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.blocks-container { padding: 8px 16px 96px; }
|
||||
.block-handle { display: none; }
|
||||
.block-actions { display: none; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
.page-title-block .page-title-input { padding-left: 0; }
|
||||
.editor-statusbar { left: 0; }
|
||||
|
||||
/* ── View Tabs ── */
|
||||
@@ -3738,6 +3884,9 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.page-title-input { font-size: 22px; }
|
||||
.blocks-container { padding: 6px 12px 80px; }
|
||||
.page-icon-emoji { font-size: 28px; }
|
||||
.page-title-block::before { display: none; }
|
||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||
.page-title-block .page-title-input { padding-left: 0; }
|
||||
|
||||
.kanban-column {
|
||||
min-width: 92vw;
|
||||
@@ -4747,3 +4896,19 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.aici-result a { color: var(--accent,#2383E2); }
|
||||
.aici-fb:disabled { opacity:.4; cursor:default; }
|
||||
@media (max-width:768px){ #_aiC { width: 92vw; } }
|
||||
|
||||
/* v5.5.0 — Image lightbox */
|
||||
.fd-lightbox { user-select: none; }
|
||||
.fd-lightbox-img { animation: fdLbIn .12s ease-out; }
|
||||
@keyframes fdLbIn { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
|
||||
.fd-lightbox-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
border: none; background: rgba(255,255,255,.12); color: #fff;
|
||||
font-size: 30px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); }
|
||||
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
|
||||
.fd-lightbox-nav[data-nav="next"] { right: 24px; }
|
||||
|
||||
@@ -233,3 +233,165 @@
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
// ── Partial navigation (HTMX) ───────────────────────────────
|
||||
// The shell (sidebar + topbar) stays in place and only `.main-wrapper`
|
||||
// is swapped, so clicking the left navigation no longer reloads the
|
||||
// whole document. Internal links are intercepted and loaded with
|
||||
// htmx.ajax(); history is managed manually so the back button works.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function isPlainLeftClick(evt) {
|
||||
return evt.button === 0 && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey && !evt.altKey;
|
||||
}
|
||||
|
||||
// Returns true when a link should be handled by the partial navigator.
|
||||
// External links, downloads, auth, API/static files and links already
|
||||
// managed by Alpine or HTMX keep their default behaviour.
|
||||
function shouldIntercept(a, evt) {
|
||||
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
|
||||
if (a.hasAttribute('data-no-nav')) return false;
|
||||
var target = (a.getAttribute('target') || '').toLowerCase();
|
||||
if (target && target !== '_self') return false;
|
||||
if (a.hasAttribute('download')) return false;
|
||||
if (a.getAttribute('hx-boost') === 'false') return false;
|
||||
var attrs = a.getAttributeNames();
|
||||
for (var i = 0; i < attrs.length; i++) {
|
||||
var n = attrs[i];
|
||||
if (n.indexOf('@click') === 0 || n.indexOf('x-on:click') === 0) return false;
|
||||
if (n.indexOf('hx-') === 0) return false;
|
||||
}
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (!href || href.charAt(0) === '#') return false;
|
||||
if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
|
||||
var u;
|
||||
try { u = new URL(a.href, window.location.href); } catch { return false; }
|
||||
if (u.origin !== window.location.origin) return false;
|
||||
var p = u.pathname;
|
||||
if (p === '/auth' || p.indexOf('/auth/') === 0) return false;
|
||||
if (p.indexOf('/api/') === 0) return false;
|
||||
if (p.indexOf('/static/') === 0) return false;
|
||||
if (p === '/p' || p.indexOf('/p/') === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
var loading = false;
|
||||
|
||||
function fdLoad(url, push) {
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
document.documentElement.classList.add('fd-navigating');
|
||||
function done() {
|
||||
loading = false;
|
||||
document.documentElement.classList.remove('fd-navigating');
|
||||
}
|
||||
var p;
|
||||
try {
|
||||
p = window.htmx.ajax('GET', url, {
|
||||
target: '.main-wrapper',
|
||||
select: '.main-wrapper',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
} catch {
|
||||
done();
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
if (p && typeof p.then === 'function') {
|
||||
p.then(function() {
|
||||
done();
|
||||
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
||||
}).catch(function() {
|
||||
done();
|
||||
window.location.href = url;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(evt) {
|
||||
var a = evt.target && evt.target.closest ? evt.target.closest('a[href]') : null;
|
||||
if (!shouldIntercept(a, evt)) return;
|
||||
evt.preventDefault();
|
||||
fdLoad(a.href, true);
|
||||
});
|
||||
|
||||
// Back/forward buttons reload the content for the target URL.
|
||||
window.addEventListener('popstate', function() {
|
||||
fdLoad(window.location.href, false);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent('fd-toggle-sidebar'));
|
||||
}
|
||||
});
|
||||
|
||||
// Programmatic navigation used by Alpine helpers.
|
||||
window.fdNavigate = function(url, opts) {
|
||||
opts = opts || {};
|
||||
if (!url) return;
|
||||
if (opts.full || !window.htmx) { window.location.href = url; return; }
|
||||
fdLoad(url, true);
|
||||
};
|
||||
|
||||
function syncAfterSwap(xhr) {
|
||||
try {
|
||||
if (xhr && xhr.responseText) {
|
||||
var doc = new DOMParser().parseFromString(xhr.responseText, 'text/html');
|
||||
var title = doc.querySelector('title');
|
||||
if (title && title.textContent) document.title = title.textContent;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
var path = window.location.pathname;
|
||||
var search = window.location.search;
|
||||
|
||||
document.querySelectorAll('.sidebar-item.active').forEach(function(el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.sidebar-item').forEach(function(el) {
|
||||
var href = el.getAttribute('href');
|
||||
var a = el.querySelector('a[href]');
|
||||
if (a) href = a.getAttribute('href');
|
||||
if (href && (href === path || href === path + search)) el.classList.add('active');
|
||||
var pid = el.getAttribute('data-page-id');
|
||||
if (pid && ('/pages/' + String(pid).replace('page/', '')) === path) el.classList.add('active');
|
||||
var dbid = el.getAttribute('data-id');
|
||||
if (dbid && search.indexOf('folder=' + dbid) !== -1) el.classList.add('active');
|
||||
});
|
||||
|
||||
if (window.appState) {
|
||||
var m = search.match(/[?&]folder=(\d+)/);
|
||||
window.appState.activeNodeId = m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// htmx parses the WHOLE response and executes every <script> it contains,
|
||||
// including shell scripts (agent panel, base inline scripts) that live
|
||||
// outside .main-wrapper. Re-running those corrupts Alpine state (x-for
|
||||
// teardown errors). Before the swap, reduce the response to the
|
||||
// .main-wrapper fragment so only page scripts are executed.
|
||||
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
||||
var d = evt.detail;
|
||||
if (!d || !d.target || !d.target.classList || !d.target.classList.contains('main-wrapper')) return;
|
||||
if (!d.serverResponse || typeof d.serverResponse !== 'string') return;
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(d.serverResponse, 'text/html');
|
||||
var mw = doc.querySelector('.main-wrapper');
|
||||
if (mw) d.serverResponse = mw.outerHTML;
|
||||
} catch { /* keep original response on parse failure */ }
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
syncAfterSwap(evt.detail && evt.detail.xhr);
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
"""FlowDeck — v5.5.0 "Embeds & Média riche" test suite.
|
||||
|
||||
Covers the five roadmap points:
|
||||
1. Universal embed block → app/services/embeds.py + /board/api/embed/resolve
|
||||
2. Bookmark cards → app/services/og_fetcher.py + /board/api/og/metadata
|
||||
3. Image lightbox → editor + public page markup
|
||||
4. Inline previews → PDF / video / audio rendering
|
||||
5. Cover & page icon → /board/api/pages/{id}/cover|icon + public page
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _login(client):
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
login = f"testv55_{secrets.token_hex(4)}"
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
"VALUES (?, 'Test V55', ?, '', 1)",
|
||||
(login, f"{login}@test.com"),
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
|
||||
r = client.get("/api/csrf-token", cookies={"flowdeck_session": session})
|
||||
return session, r.json()["csrf_token"]
|
||||
|
||||
|
||||
def _workspace(client, cookie, name="V55 WS"):
|
||||
r = client.post("/workspace", json={"name": name}, cookies={"flowdeck_session": cookie})
|
||||
assert r.status_code == 200
|
||||
ws = r.json()
|
||||
client.cookies.set("flowdeck_workspace", str(ws["id"]))
|
||||
return ws["id"]
|
||||
|
||||
|
||||
def _page(client, cookie, csrf, title="V55 Page"):
|
||||
r = client.post(
|
||||
f"/board/api/pages?title={title}§ion=Private&parent_id=0",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _save_blocks(client, cookie, csrf, page_id, blocks, title="V55 Page"):
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"title": title, "blocks": blocks},
|
||||
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
def _cookie_headers(cookie, csrf=None):
|
||||
h = {}
|
||||
if csrf:
|
||||
h["X-CSRF-Token"] = csrf
|
||||
return h
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 1. Universal embed
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEmbedService:
|
||||
def test_youtube_variants(self):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
assert embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ") == (
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
)
|
||||
assert embed_src("https://youtu.be/dQw4w9WgXcQ") == (
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
)
|
||||
assert embed_src("https://www.youtube.com/shorts/abcdef123") == (
|
||||
"https://www.youtube.com/embed/abcdef123"
|
||||
)
|
||||
assert "start=42" in embed_src("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,needle",
|
||||
[
|
||||
("https://vimeo.com/123456789", "player.vimeo.com/video/123456789"),
|
||||
("https://www.figma.com/file/abc123/Doc", "figma.com/embed"),
|
||||
("https://www.google.com/maps/place/Eiffel", "maps.google.com/maps?q="),
|
||||
("https://docs.google.com/document/d/ABC123/edit", "docs.google.com/document/d/ABC123/preview"),
|
||||
("https://docs.google.com/spreadsheets/d/SHEET1/edit", "docs.google.com/spreadsheets/d/SHEET1/preview"),
|
||||
("https://www.loom.com/share/" + "a" * 32, "loom.com/embed/" + "a" * 32),
|
||||
("https://codepen.io/user/pen/abc123", "codepen.io/user/embed/abc123"),
|
||||
("https://miro.com/app/board/uXjV=", "miro.com/app/live-embed/uXjV="),
|
||||
("https://open.spotify.com/track/abc123", "open.spotify.com/embed/track/abc123"),
|
||||
("https://soundcloud.com/artist/track", "w.soundcloud.com/player/"),
|
||||
("https://www.twitch.tv/somestreamer", "player.twitch.tv/?channel=somestreamer"),
|
||||
("https://twitter.com/user/status/123456", "Tweet.html?id=123456"),
|
||||
("https://x.com/user/status/123456", "Tweet.html?id=123456"),
|
||||
("https://www.pinterest.com/pin/12345/", "pinterest.com/pin/embed"),
|
||||
("https://example.com/report.docx", "view.officeapps.live.com/op/embed.aspx"),
|
||||
],
|
||||
)
|
||||
def test_providers(self, url, needle):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
out = embed_src(url)
|
||||
assert out and needle in out
|
||||
|
||||
def test_twitch_parent_is_used(self):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
out = embed_src("https://www.twitch.tv/chan", parent="flowdeck.example.com")
|
||||
assert out.endswith("parent=flowdeck.example.com")
|
||||
|
||||
def test_unknown_http_falls_back_to_iframe(self):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
assert embed_src("https://example.com/page") == "https://example.com/page"
|
||||
|
||||
def test_bare_domain_is_upgraded_to_https(self):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
assert embed_src("example.com/page") == "https://example.com/page"
|
||||
|
||||
def test_non_http_is_rejected(self):
|
||||
from app.services.embeds import embed_src
|
||||
|
||||
assert embed_src("mailto:[email protected]") is None
|
||||
assert embed_src("") is None
|
||||
|
||||
def test_inline_kind(self):
|
||||
from app.services.embeds import inline_kind
|
||||
|
||||
assert inline_kind("https://ex.com/a.png") == "image"
|
||||
assert inline_kind("https://ex.com/a.pdf") == "pdf"
|
||||
assert inline_kind("https://ex.com/a.mp4") == "video"
|
||||
assert inline_kind("https://ex.com/a.mp3") == "audio"
|
||||
assert inline_kind("https://ex.com/page") == "iframe"
|
||||
assert inline_kind("nonsense") is None
|
||||
|
||||
def test_resolve_embed(self):
|
||||
from app.services.embeds import resolve_embed
|
||||
|
||||
out = resolve_embed("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
||||
assert out["provider"] == "youtube"
|
||||
assert out["kind"] == "iframe"
|
||||
assert out["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
|
||||
def test_embed_html_is_responsive(self):
|
||||
from app.services.embeds import embed_html
|
||||
|
||||
html = embed_html("https://ex.com/embed", height=360)
|
||||
assert "<iframe" in html
|
||||
assert "height:360px" in html
|
||||
|
||||
|
||||
class TestEmbedResolveEndpoint:
|
||||
def test_resolve_endpoint(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
r = client.post(
|
||||
"/board/api/embed/resolve",
|
||||
json={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
assert data["provider"] == "youtube"
|
||||
|
||||
def test_resolve_requires_url(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
r = client.post(
|
||||
"/board/api/embed/resolve",
|
||||
json={},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 2. Bookmark cards / Open Graph
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
_OG_HTML = """<!doctype html><html><head>
|
||||
<title>Fallback title</title>
|
||||
<meta content="FlowDeck — Notion clone" property="og:title">
|
||||
<meta property="og:description" content="Plan, write and ship.">
|
||||
<meta name="twitter:image" content="/static/preview.png">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
</head><body>hi</body></html>"""
|
||||
|
||||
|
||||
class TestOGParser:
|
||||
def test_parse_og_handles_attribute_order(self):
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
data = parse_og(_OG_HTML, "https://flowdeck.example.com/page")
|
||||
assert data["title"] == "FlowDeck — Notion clone"
|
||||
assert data["description"] == "Plan, write and ship."
|
||||
assert data["site_name"] == "FlowDeck"
|
||||
assert data["image"] == "https://flowdeck.example.com/static/preview.png"
|
||||
assert data["favicon"] == "https://flowdeck.example.com/favicon.ico"
|
||||
|
||||
def test_parse_og_title_fallback(self):
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
data = parse_og("<html><head><title>Plain title</title></head></html>", "https://ex.com")
|
||||
assert data["title"] == "Plain title"
|
||||
|
||||
def test_fetch_og_metadata_success(self):
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, headers={"content-type": "text/html"}, text=_OG_HTML)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
data = asyncio.run(fetch_og_metadata("https://flowdeck.example.com/page", transport=transport))
|
||||
assert data["title"] == "FlowDeck — Notion clone"
|
||||
assert data["site_name"] == "FlowDeck"
|
||||
|
||||
def test_fetch_og_metadata_non_html(self):
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, headers={"content-type": "application/pdf"}, content=b"%PDF")
|
||||
|
||||
data = asyncio.run(
|
||||
fetch_og_metadata("https://example.com/file.pdf", transport=httpx.MockTransport(handler))
|
||||
)
|
||||
assert data["title"] == "example.com"
|
||||
|
||||
def test_fetch_og_metadata_network_error_is_safe(self):
|
||||
from app.services.og_fetcher import fetch_og_metadata
|
||||
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
data = asyncio.run(
|
||||
fetch_og_metadata("https://unreachable.example.com", transport=httpx.MockTransport(handler))
|
||||
)
|
||||
assert data["url"].startswith("https://unreachable.example.com")
|
||||
assert data["title"] == "unreachable.example.com"
|
||||
|
||||
|
||||
class TestOGMetadataEndpoint:
|
||||
def test_endpoint_returns_payload(self, client, monkeypatch):
|
||||
cookie, csrf = _login(client)
|
||||
|
||||
async def fake_fetch(url, timeout=6.0, transport=None):
|
||||
return {
|
||||
"url": url,
|
||||
"title": "Mocked title",
|
||||
"description": "Mocked description",
|
||||
"image": "https://ex.com/img.png",
|
||||
"site_name": "Ex",
|
||||
"favicon": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.services.og_fetcher.fetch_og_metadata", fake_fetch)
|
||||
r = client.post(
|
||||
"/board/api/og/metadata",
|
||||
json={"url": "https://ex.com"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["title"] == "Mocked title"
|
||||
|
||||
|
||||
class TestBookmarkPersistence:
|
||||
def test_bookmark_roundtrip(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Bookmark")
|
||||
blocks = [{
|
||||
"type": "bookmark",
|
||||
"url": "https://example.com",
|
||||
"title": "Example",
|
||||
"description": "A test site",
|
||||
"image": "https://example.com/og.png",
|
||||
"site_name": "Example",
|
||||
}]
|
||||
_save_blocks(client, cookie, csrf, page, blocks, "Bookmark")
|
||||
saved = json.loads(
|
||||
client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"]
|
||||
)
|
||||
assert saved[0]["type"] == "bookmark"
|
||||
assert saved[0]["image"] == "https://example.com/og.png"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 3. Image lightbox
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestLightbox:
|
||||
def test_editor_lightbox_supports_keyboard_navigation(self):
|
||||
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
assert "_openLightbox" in tpl
|
||||
assert "fd-lightbox-nav" in tpl
|
||||
assert "ArrowRight" in tpl and "ArrowLeft" in tpl
|
||||
assert "Escape" in tpl
|
||||
|
||||
def test_public_page_lightbox_markup(self):
|
||||
tpl = Path("app/templates/public_page.html").read_text(encoding="utf-8")
|
||||
assert "fd-lightbox" in tpl
|
||||
assert "data-full" in tpl
|
||||
assert "ArrowRight" in tpl and "ArrowLeft" in tpl
|
||||
|
||||
def test_public_image_has_zoom_cursor(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
html = _render_blocks_public([{"type": "image", "src": "https://ex.com/a.png", "alt": "A"}])
|
||||
assert 'data-full="https://ex.com/a.png"' in html
|
||||
assert "cursor:zoom-in" in html
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 4. Inline previews (PDF / video / audio)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestInlinePreviews:
|
||||
def test_editor_renders_video_audio_pdf(self):
|
||||
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
assert "<video controls" in tpl
|
||||
assert "<audio controls" in tpl
|
||||
assert "embed_type==='pdf'" in tpl
|
||||
|
||||
def test_public_renders_video_audio_pdf(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
blocks = [
|
||||
{"type": "video", "src": "https://ex.com/v.mp4"},
|
||||
{"type": "audio", "src": "https://ex.com/a.mp3"},
|
||||
{"type": "embed", "src": "https://ex.com/doc.pdf", "embed_type": "pdf"},
|
||||
]
|
||||
html = _render_blocks_public(blocks)
|
||||
assert "<video" in html
|
||||
assert "<audio" in html
|
||||
assert "doc.pdf" in html and "<iframe" in html
|
||||
|
||||
def test_public_embed_uses_resolved_src(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
html = _render_blocks_public([{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
}])
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
||||
|
||||
def test_public_embed_resolves_when_no_cached_src(self):
|
||||
from app.routers.dashboard import _render_blocks_public
|
||||
|
||||
html = _render_blocks_public([{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
}])
|
||||
assert "youtube.com/embed/dQw4w9WgXcQ" in html
|
||||
|
||||
def test_embed_src_field_persists(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Embed persist")
|
||||
blocks = [{
|
||||
"type": "embed",
|
||||
"src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"embed_src": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"embed_provider": "youtube",
|
||||
"height": 480,
|
||||
}]
|
||||
_save_blocks(client, cookie, csrf, page, blocks, "Embed persist")
|
||||
saved = json.loads(
|
||||
client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}).json()["content"]
|
||||
)
|
||||
assert saved[0]["embed_src"] == "https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
assert saved[0]["embed_provider"] == "youtube"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 5. Cover & page icon
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCoverIcon:
|
||||
def test_set_and_remove_cover_url(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Cover")
|
||||
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
json={"cover_url": "https://example.com/cover.png"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["cover_url"] == "https://example.com/cover.png"
|
||||
|
||||
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r2.json()["cover_url"] == "https://example.com/cover.png"
|
||||
|
||||
r3 = client.delete(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r3.status_code == 200
|
||||
r4 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r4.json()["cover_url"] in ("", None)
|
||||
|
||||
def test_upload_cover_file(self, client, monkeypatch, tmp_path):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Cover upload")
|
||||
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
files={"file": ("banner.png", png, "image/png")},
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
url = r.json()["cover_url"]
|
||||
assert url.startswith("/api/files/")
|
||||
assert url.endswith(".png")
|
||||
assert (tmp_path / "uploads").exists()
|
||||
|
||||
def test_upload_rejects_non_image(self, client, monkeypatch, tmp_path):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Bad upload")
|
||||
monkeypatch.setenv("FLOWDECK_DATA_DIR", str(tmp_path))
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
files={"file": ("evil.sh", b"#!/bin/sh", "text/plain")},
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_set_page_icon(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Icon")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
json={"icon": "🚀"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["icon"] == "🚀"
|
||||
r2 = client.get(f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert r2.json()["page_icon"] == "🚀"
|
||||
|
||||
def test_icon_too_long_rejected(self, client):
|
||||
cookie, csrf = _login(client)
|
||||
_workspace(client, cookie)
|
||||
page = _page(client, cookie, csrf, "Icon long")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
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)
|
||||
page = _page(client, cookie, csrf, "Public")
|
||||
_save_blocks(client, cookie, csrf, page, [{"type": "paragraph", "content": "Hi"}], "Public")
|
||||
client.post(
|
||||
f"/board/api/pages/{page}/cover",
|
||||
json={"cover_url": "https://example.com/c.jpg"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
client.post(
|
||||
f"/board/api/pages/{page}/icon",
|
||||
json={"icon": "🌟"},
|
||||
headers={"Content-Type": "application/json", **_cookie_headers(cookie, csrf)},
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/pages/{page}/publish",
|
||||
headers=_cookie_headers(cookie, csrf),
|
||||
cookies={"flowdeck_session": cookie},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
slug = r.json()["publish_slug"]
|
||||
html = client.get(f"/p/{slug}").text
|
||||
assert "c.jpg" in html
|
||||
assert "🌟" in html
|
||||
assert "fd-lightbox" in html
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Regression: the editor must call the board-owned endpoints (v5.5.0 bug)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEditorEndpointPaths:
|
||||
def test_editor_uses_board_prefixed_endpoints(self):
|
||||
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
for endpoint in (
|
||||
"/board/api/pages/'+this.pid+'/cover",
|
||||
"/board/api/pages/'+this.pid+'/icon",
|
||||
"/board/api/pages/'+this.pid+'/versions",
|
||||
"/board/api/pages/'+this.pid+'/backlinks",
|
||||
"/board/api/og/metadata",
|
||||
"/board/api/embed/resolve",
|
||||
):
|
||||
assert endpoint in tpl, f"missing editor endpoint: {endpoint}"
|
||||
# the old, broken paths must be gone
|
||||
assert "fetch('/api/pages/'+this.pid+'/cover" not in tpl
|
||||
assert "fetch('/api/pages/'+this.pid+'/icon" not in tpl
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user