feat(gallery): lucide/flag icon redesign + community marketplace (omnivoice-gallery) (#207)

* feat(gallery): lucide icons + country flags + card redesign (replace emoji)

- backend archetypes emit lucide-react icon *names* (cross-platform; emoji
  render inconsistently across OSes) for use-cases and the 24 featured voices.
- new frontend/src/utils/archetypeIcons.jsx: name→lucide map, accent→country
  flag (country-flag-icons, tree-shaken to ~11), per-category color scale,
  color-coded avatar tile, and a CSS-animated now-playing equalizer
  (prefers-reduced-motion aware).
- card redesign: real elevated surfaces (cards were invisible on the dark bg),
  avatar + name + facet sub-line, accent/flag chips, and a footer with
  Preview / category-colored "Use voice" / Open-in-Designer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(marketplace): community voice gallery via omnivoice-gallery submodule

Offloads curated + community gallery content to the standalone
debpalash/omnivoice-gallery repo — added here as a submodule for authoring,
loaded at runtime via the jsDelivr CDN so the binary stays small.

Content repo (seeded + pushed separately): manifest.json (24-voice starter
pack generated from the featured archetypes), a JSON schema, CONTRIBUTING,
and GitHub submission templates carrying consent / no-impersonation guardrails.

Backend (api/routers/community.py): configurable sources (env var > file >
default), CDN fetch with offline disk cache, strict validation (invalid
presets and non-allow-listed audio URLs are dropped, so a bad community entry
can neither crash synthesis nor fetch from an arbitrary host), filtering, the
prefilled submit URL, and "use" (preset → archetype render path; voice →
sha256-verified download). 11 tests.

Frontend: a third gallery zone, "Community", reusing the redesigned card, plus
"Submit a preset / voice" buttons opening the prefilled GitHub forms.

Local-first preserved: network only on open/refresh; everything cached; the
built-in generated archetypes need no network, so the gallery is never empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(marketplace): address review feedback on the community gallery

- community_use: run the blocking manifest read + voice download in a thread
  (asyncio.to_thread) so they don't stall the event loop (greptile P2).
- community_submit_url: validate the `source` override against an owner/repo
  pattern, falling back to the configured default (greptile P1 hardening).
- rename the `type` query param to `item_type` (alias="type") so it no longer
  shadows the Python builtin (coderabbit).
- frontend submit buttons use the canonical openExternal() (Tauri-aware) instead
  of window.open, which doesn't open the system browser in the desktop app.
- lowercase the `currentcolor` CSS keyword (stylelint).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(marketplace): rename useCommunityItem -> addCommunityItem (not a hook)

Avoids the use-prefix on a plain API function (rules-of-hooks smell).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-05-31 22:24:31 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 9e04d5c683
commit 253122325c
14 changed files with 847 additions and 80 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "omnivoice-gallery"]
path = omnivoice-gallery
url = https://github.com/debpalash/omnivoice-gallery.git
+292
View File
@@ -0,0 +1,292 @@
"""Community gallery (marketplace) API.
Loads designed *presets* and recorded *voices* from configured content repos
(default: ``debpalash/omnivoice-gallery``) over the jsDelivr CDN, caches them
locally, validates strictly, and exposes them to the gallery.
Design / safety
===============
* **Local-first.** The network is touched only when the user opens the
marketplace or hits refresh. Everything is cached under
``DATA_DIR/gallery_cache`` and served offline from cache; the app's built-in
generated archetypes need no network, so the gallery is never empty.
* **Data only, never code.** Remote content is JSON + audio. Presets are
validated against the engine's taxonomy and *dropped* if invalid (so a bad
community entry can't reproduce issue #89). Audio URLs are restricted to an
allow-list of hosts (jsDelivr / GitHub) — no arbitrary SSRF target.
* **Reuse.** "Use a preset" renders through the same path as archetypes
(one TTS code path).
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query
from core import archetypes
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.community")
router = APIRouter()
_CACHE_DIR = Path(DATA_DIR) / "gallery_cache"
_DEFAULT_SOURCES = ["debpalash/omnivoice-gallery"]
_ALLOWED_AUDIO_HOSTS = {
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
}
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
# ── Config: which content repos to load ───────────────────────────────────────
def configured_sources() -> list[str]:
"""Gallery sources, in priority order. Env var > config file > default."""
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
if env:
return [s.strip() for s in env.split(",") if s.strip()]
cfg = Path(DATA_DIR) / "gallery_sources.json"
if cfg.exists():
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
srcs = data.get("sources")
if isinstance(srcs, list) and srcs:
return [str(s) for s in srcs]
except Exception:
logger.warning("gallery_sources.json unreadable; using default")
return list(_DEFAULT_SOURCES)
def _manifest_url(source: str) -> str:
return f"https://cdn.jsdelivr.net/gh/{source}@main/manifest.json"
def _cache_path(source: str) -> Path:
return _CACHE_DIR / source.replace("/", "__") / "manifest.json"
def _safe_audio_url(url: str) -> bool:
try:
u = urlparse(url or "")
return u.scheme == "https" and (u.hostname in _ALLOWED_AUDIO_HOSTS)
except Exception:
return False
def is_valid_instruct(instruct: str) -> bool:
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
def validate_item(raw: dict) -> Optional[dict]:
"""Return a normalized item, or None if it must be dropped."""
if not isinstance(raw, dict):
return None
it = dict(raw)
if it.get("type") not in ("preset", "voice"):
return None
if not it.get("id") or not it.get("name"):
return None
if it.get("use_case") not in _USE_CASE_IDS:
return None
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
return None # would crash synthesis — drop it
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
return None
it.setdefault("facets", {})
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
it["is_community"] = it.get("source") != "starter"
return it
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
items, packs, seen = [], [], set()
for src, m in manifests:
if not m:
continue
for raw in (m.get("items") or []):
v = validate_item(raw)
if v and v["id"] not in seen:
v["_source_repo"] = src
seen.add(v["id"])
items.append(v)
for p in (m.get("packs") or []):
if isinstance(p, dict):
packs.append({**p, "_source_repo": src})
return items, packs
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
cache = _cache_path(source)
if not refresh and cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
try:
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(_manifest_url(source))
resp.raise_for_status()
data = resp.json()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as e: # offline / 404 / bad json
logger.warning("manifest fetch failed for %s: %s", source, e)
if cache.exists():
try:
return json.loads(cache.read_text(encoding="utf-8"))
except Exception:
pass
return None
def _load(refresh: bool) -> tuple[list[str], list, list, bool]:
srcs = configured_sources()
manifests = [(s, _fetch_manifest(s, refresh)) for s in srcs]
items, packs = _merge(manifests)
offline = all(m is None for _, m in manifests)
return srcs, items, packs, offline
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/community/sources")
def community_sources():
"""The content repos the gallery loads from (default: omnivoice-gallery)."""
return {"sources": configured_sources()}
@router.get("/community/manifest")
def community_manifest(refresh: bool = Query(False)):
srcs, items, packs, offline = _load(refresh)
return {"sources": srcs, "packs": packs, "items": items, "count": len(items), "offline": offline}
@router.get("/community/items")
def community_items(
use_case: Optional[str] = None,
gender: Optional[str] = None,
item_type: Optional[str] = Query(None, alias="type"),
lang: Optional[str] = None,
q: Optional[str] = None,
limit: int = Query(60, ge=1, le=500),
offset: int = Query(0, ge=0),
refresh: bool = Query(False),
):
_, items, _, _ = _load(refresh)
def keep(it: dict) -> bool:
f = it.get("facets", {})
if use_case and it.get("use_case") != use_case:
return False
if gender and f.get("gender") != gender:
return False
if item_type and it.get("type") != item_type:
return False
if lang and it.get("language") != lang:
return False
if q and q.lower() not in (it.get("name", "").lower()):
return False
return True
items = [it for it in items if keep(it)]
return {"total": len(items), "limit": limit, "offset": offset, "items": items[offset:offset + limit]}
@router.get("/community/submit-url")
def community_submit_url(item_type: str = Query("preset", alias="type"), source: Optional[str] = Query(None)):
"""Build the prefilled GitHub submission URL (server-free, local-first)."""
src = source or configured_sources()[0]
if not _SOURCE_RE.match(src or ""):
src = configured_sources()[0] # ignore a malformed/untrusted source override
template = "preset-submission.yml" if item_type == "preset" else "voice-submission.yml"
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
@router.post("/community/items/{item_id}/use")
async def community_use(item_id: str, name: Optional[str] = Query(None)):
"""Materialize a community item into a reusable voice profile.
Preset → render through the archetype engine. Voice → download the
(host-allow-listed, SHA-256-verified) reference clip. Both create a
``voice_profiles`` row usable everywhere voices are picked.
"""
_, items, _, _ = await asyncio.to_thread(_load, False)
item = next((it for it in items if it["id"] == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
import time
import uuid
from core import event_bus
from core.db import db_conn
from core.config import VOICES_DIR
profile_id = str(uuid.uuid4())[:8]
audio_filename = f"{profile_id}.wav"
audio_path = Path(VOICES_DIR) / audio_filename
profile_name = (name or item["name"]).strip() or item["name"]
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
try:
if item["type"] == "preset":
from api.routers.archetypes import _render_archetype_wav
pseudo = {
"instruct": instruct,
"language": item.get("language", "English"),
"sample_script": ref_text or "Hello — this is a preview of this voice.",
}
await _render_archetype_wav(pseudo, audio_path)
else: # voice — download the reference clip (off the event loop)
await asyncio.to_thread(_download_voice_audio, item, audio_path)
except HTTPException:
raise
except Exception as e:
logger.error("Community 'use' failed", exc_info=True)
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
try:
with db_conn() as conn:
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(profile_id, profile_name, audio_filename, ref_text, instruct,
item.get("language", "Auto"), None, item["id"], time.time()),
)
except Exception:
with __import__("contextlib").suppress(OSError):
os.remove(audio_path)
raise
event_bus.emit("profiles", {"action": "created", "id": profile_id})
return {"profile_id": profile_id, "name": profile_name}
def _download_voice_audio(item: dict, out_path: Path) -> None:
import hashlib
audio = item.get("audio") or {}
url = audio.get("url", "")
if not _safe_audio_url(url):
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
import httpx
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.content
expected = audio.get("sha256")
if expected and hashlib.sha256(data).hexdigest() != expected:
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
+34 -31
View File
@@ -82,14 +82,17 @@ _DIALECTS_SORTED = sorted(_CAT[5]) # 12 Chinese dialects
# ── Use-case categories (replaces the named-real-person buckets) ──────────────
# `icon` values are lucide-react component names; the frontend maps them to SVG
# components (see frontend/src/utils/archetypeIcons.jsx). No emoji — they render
# inconsistently across OSes.
USE_CASES = [
{"id": "narration", "name": "Narration & Story", "icon": "📖"},
{"id": "conversational", "name": "Conversational", "icon": "💬"},
{"id": "characters", "name": "Characters & Animation", "icon": "🎭"},
{"id": "social", "name": "Social Media", "icon": "📱"},
{"id": "entertainment", "name": "Entertainment & TV", "icon": "📺"},
{"id": "advertisement", "name": "Advertisement", "icon": "📣"},
{"id": "informative", "name": "Informative & Educational", "icon": "🎓"},
{"id": "narration", "name": "Narration & Story", "icon": "BookOpen"},
{"id": "conversational", "name": "Conversational", "icon": "MessagesSquare"},
{"id": "characters", "name": "Characters & Animation", "icon": "Drama"},
{"id": "social", "name": "Social Media", "icon": "Smartphone"},
{"id": "entertainment", "name": "Entertainment & TV", "icon": "Tv"},
{"id": "advertisement", "name": "Advertisement", "icon": "Megaphone"},
{"id": "informative", "name": "Informative & Educational", "icon": "GraduationCap"},
]
_USE_ICON = {c["id"]: c["icon"] for c in USE_CASES}
@@ -260,36 +263,36 @@ def _build(gender, age, pitch, *, accent=None, dialect=None, whisper=False,
# (gender, age, pitch, accent, whisper, use_case, name, icon)
_FEATURED_SPEC = [
# Narration & Story
("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "📚"),
("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "🎙️"),
("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "🌙"),
("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "🧙"),
("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "Library"),
("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "Mic"),
("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "Moon"),
("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "Wand2"),
# Conversational
("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "😊"),
("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "🎧"),
("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "🙂"),
("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", ""),
("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "Smile"),
("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "Headphones"),
("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "MessageSquare"),
("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", "Coffee"),
# Characters & Animation
("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "☠️"),
(None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "🦆"),
("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "🦸"),
("female", "child", "very high pitch", None, False, "characters", "The Pixie", "🧚"),
("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "👹"),
("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "Skull"),
(None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "Bird"),
("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "Shield"),
("female", "child", "very high pitch", None, False, "characters", "The Pixie", "Sparkles"),
("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "Ghost"),
# Social Media
("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "🎤"),
("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", ""),
("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "📱"),
("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "Radio"),
("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", "Zap"),
("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "Video"),
# Entertainment & TV
("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "📺"),
("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "🏟️"),
("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "🎬"),
("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "Tv"),
("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "Trophy"),
("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "Clapperboard"),
# Advertisement
("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "📣"),
("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "💎"),
("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", ""),
("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "Megaphone"),
("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "Gem"),
("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", "Music"),
# Informative & Educational
("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "👩‍🏫"),
("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "💡"),
("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "GraduationCap"),
("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "Lightbulb"),
]
+2
View File
@@ -299,6 +299,7 @@ from api.routers import (
setup,
gallery,
archetypes,
community,
batch,
watermark,
events,
@@ -626,6 +627,7 @@ app.include_router(stories.router)
app.include_router(setup.router)
app.include_router(gallery.router)
app.include_router(archetypes.router)
app.include_router(community.router)
app.include_router(batch.router)
app.include_router(watermark.router)
app.include_router(events.router)
+129
View File
@@ -0,0 +1,129 @@
"""Tests for the community gallery (marketplace) loader.
Covers the no-network surface: strict item validation (invalid presets and
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
the prefilled submit URL. The render/download paths need the model/network and
are exercised at runtime.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_TMP = tempfile.mkdtemp(prefix="omnivoice_community_test_")
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(Path(_TMP) / "voices")
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
sys.modules["core.config"] = _config
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from api.routers import community # noqa: E402
_FIXTURE = {
"schema_version": 1,
"items": [
{"id": "p1", "type": "preset", "name": "Test Narrator", "use_case": "narration",
"facets": {"gender": "female", "age": "middle-aged", "pitch": "low pitch", "lang": "English"},
"instruct": "female, middle-aged, low pitch", "language": "English", "source": "community"},
# invalid instruct token -> dropped
{"id": "p_bad", "type": "preset", "name": "Bad", "use_case": "narration",
"instruct": "female, raspy, smoky"},
# unsafe audio host -> dropped
{"id": "v_bad", "type": "voice", "name": "Sketchy", "use_case": "narration",
"audio": {"url": "http://evil.example.com/x.wav"}},
# valid voice (allow-listed host)
{"id": "v1", "type": "voice", "name": "Recorded One", "use_case": "narration",
"facets": {"gender": "male", "lang": "English"},
"audio": {"url": "https://github.com/debpalash/omnivoice-gallery/releases/download/voices-v1/v1.wav"}},
# unknown use_case -> dropped
{"id": "u1", "type": "preset", "name": "Mystery", "use_case": "banana", "instruct": "male"},
],
"packs": [{"id": "starter", "name": "Starter", "item_ids": ["p1"]}],
}
@pytest.fixture(scope="module", autouse=True)
def seed_cache():
cache = community._cache_path("debpalash/omnivoice-gallery")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
yield
@pytest.fixture(scope="module")
def client():
app = FastAPI()
app.include_router(community.router)
return TestClient(app)
# ── pure validation ───────────────────────────────────────────────────────────
def test_valid_preset_kept():
assert community.validate_item(_FIXTURE["items"][0]) is not None
def test_invalid_instruct_dropped():
assert community.validate_item(_FIXTURE["items"][1]) is None
def test_unsafe_audio_url_dropped():
assert community.validate_item(_FIXTURE["items"][2]) is None
def test_unknown_use_case_dropped():
assert community.validate_item(_FIXTURE["items"][4]) is None
def test_is_valid_instruct():
assert community.is_valid_instruct("male, elderly, very low pitch")
assert not community.is_valid_instruct("male, sultry")
assert not community.is_valid_instruct("")
# ── merge keeps only valid items ──────────────────────────────────────────────
def test_merge_drops_invalid_and_dedups():
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
ids = {i["id"] for i in items}
assert ids == {"p1", "v1"}
assert packs and packs[0]["id"] == "starter"
# ── endpoints (served from cache, no network) ─────────────────────────────────
def test_manifest_endpoint_from_cache(client):
body = client.get("/community/manifest").json()
assert body["count"] == 2
assert {i["id"] for i in body["items"]} == {"p1", "v1"}
assert "debpalash/omnivoice-gallery" in body["sources"]
def test_items_filter_by_type(client):
body = client.get("/community/items", params={"type": "voice"}).json()
assert [i["id"] for i in body["items"]] == ["v1"]
def test_items_filter_by_use_case(client):
body = client.get("/community/items", params={"use_case": "narration"}).json()
assert body["total"] == 2
def test_sources_endpoint(client):
assert client.get("/community/sources").json()["sources"]
def test_submit_url(client):
preset = client.get("/community/submit-url", params={"type": "preset"}).json()["url"]
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
assert "voice-submission.yml" in voice
+3
View File
@@ -38,6 +38,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"country-flag-icons": "^1.6.17",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
@@ -500,6 +501,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
+1
View File
@@ -38,6 +38,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"country-flag-icons": "^1.6.17",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
+71
View File
@@ -0,0 +1,71 @@
/**
* Community gallery (marketplace) API — designed presets + recorded voices
* loaded from the omnivoice-gallery content repo via the backend
* (CDN-fetched, cached, validated). See backend/api/routers/community.py.
*/
import { apiJson } from './client';
export interface CommunityItem {
id: string;
type: 'preset' | 'voice';
name: string;
icon: string;
use_case: string;
facets: Record<string, any>;
instruct?: string;
language: string;
sample_script?: string;
audio?: { url: string; ref_text?: string; duration?: number; sha256?: string };
author?: string;
license?: string;
source?: string;
is_community?: boolean;
}
export interface CommunityPage {
total: number;
limit: number;
offset: number;
items: CommunityItem[];
}
export interface CommunityManifest {
sources: string[];
packs: any[];
items: CommunityItem[];
count: number;
offline: boolean;
}
export interface CommunityFilters {
use_case?: string | null;
gender?: string | null;
type?: string | null;
lang?: string | null;
q?: string | null;
limit?: number;
offset?: number;
refresh?: boolean;
}
export const listCommunityItems = (filters: CommunityFilters = {}): Promise<CommunityPage> => {
const qs = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
});
const q = qs.toString();
return apiJson(`/community/items${q ? `?${q}` : ''}`);
};
export const communityManifest = (refresh = false): Promise<CommunityManifest> =>
apiJson(`/community/manifest?refresh=${refresh}`);
export const communitySources = (): Promise<{ sources: string[] }> => apiJson('/community/sources');
export const communitySubmitUrl = (type: 'preset' | 'voice'): Promise<{ url: string }> =>
apiJson(`/community/submit-url?type=${type}`);
export const addCommunityItem = (id: string, name?: string): Promise<{ profile_id: string; name: string }> => {
const q = name ? `?name=${encodeURIComponent(name)}` : '';
return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' });
};
+22
View File
@@ -10,6 +10,8 @@ import * as setupApi from './setup';
import * as galleryApi from './gallery';
import * as archetypesApi from './archetypes';
import type { ArchetypeFilters } from './archetypes';
import * as communityApi from './community';
import type { CommunityFilters } from './community';
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
export const queryKeys = {
@@ -26,6 +28,8 @@ export const queryKeys = {
galleryCategories: ['gallery-categories'] as const,
archetypeCategories: ['archetype-categories'] as const,
archetypes: (filters?: any) => ['archetypes', filters] as const,
communityItems: (filters?: any) => ['community-items', filters] as const,
communityManifest: (refresh?: boolean) => ['community-manifest', !!refresh] as const,
};
// ── Polling queries (sysinfo, model status, logs) ────────────────────────
@@ -158,6 +162,24 @@ export function useArchetypes(filters: ArchetypeFilters = {}) {
});
}
// ── Community gallery (marketplace) ───────────────────────────────────────
export function useCommunityItems(filters: CommunityFilters = {}) {
return useQuery({
queryKey: queryKeys.communityItems(filters),
queryFn: () => communityApi.listCommunityItems(filters),
staleTime: 5 * 60_000,
placeholderData: keepPreviousData,
});
}
export function useCommunityManifest(refresh = false) {
return useQuery({
queryKey: queryKeys.communityManifest(refresh),
queryFn: () => communityApi.communityManifest(refresh),
staleTime: 5 * 60_000,
});
}
// ── Mutations ────────────────────────────────────────────────────────────
export function useInstallModel() {
+57 -18
View File
@@ -372,29 +372,68 @@
.gallery-content.gallery-scroll { overflow-y: auto; }
.archetype-section { margin-bottom: 14px; }
.count-badge { margin-left: 6px; padding: 1px 7px; border-radius: 10px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.65rem; font-weight: 400; }
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; }
.archetype-grid.list { display: flex; flex-direction: column; gap: 5px; }
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 10px; }
.archetype-grid.list { display: flex; flex-direction: column; gap: 6px; }
.archetype-card { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; transition: border-color 0.15s ease, background 0.15s ease; }
.archetype-card:hover { border-color: var(--border-hover); }
.archetype-card.list { flex-direction: row; align-items: center; padding: 8px 10px; }
/* Card surface — a real, elevated tile so cards read as cards on the dark bg. */
.archetype-card {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 13px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.012));
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 13px;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
}
.archetype-card:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); }
.archetype-card.playing { border-color: var(--card-accent, var(--accent)); box-shadow: 0 0 0 1px var(--card-accent, var(--accent)), 0 6px 22px rgba(0, 0, 0, 0.4); }
.fav-btn { position: absolute; top: 8px; right: 8px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; background: transparent; color: var(--text-secondary); border-radius: 6px; cursor: pointer; }
.fav-btn:hover { color: var(--accent); }
/* Header: avatar tile + title + favorite */
.arch-head { display: flex; align-items: center; gap: 10px; }
.arch-avatar { position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 11px; }
.arch-avatar-flag { position: absolute; right: -4px; bottom: -4px; display: flex; line-height: 0; border-radius: 3px; overflow: hidden; box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021); }
.accent-flag { display: block; border-radius: 2px; }
.flag-globe { color: var(--text-secondary); }
.arch-title { flex: 1; min-width: 0; }
.archetype-name { font-size: 0.84rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.archetype-sub { font-size: 0.68rem; color: var(--text-secondary); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fav-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
.fav-btn:hover { color: #fabd2f; background: rgba(255, 255, 255, 0.05); }
.fav-btn.on { color: #fabd2f; }
.archetype-card.list .fav-btn { position: static; order: 3; }
.archetype-play { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
.archetype-play:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.archetype-card.list .archetype-play { width: 30px; height: 30px; }
/* Facet chips (accent chip carries its flag) */
.archetype-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.facet-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--text-secondary); font-size: 0.64rem; line-height: 1.6; }
.facet-chip.with-flag { padding-left: 5px; }
.archetype-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.archetype-name { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.archetype-icon { font-size: 0.95rem; line-height: 1; }
.archetype-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.facet-chip { padding: 1px 7px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); font-size: 0.62rem; }
.archetype-actions { display: flex; gap: 5px; }
.archetype-card.list .archetype-actions { margin-left: auto; }
/* Footer actions */
.arch-foot { display: flex; align-items: center; gap: 6px; margin-top: auto; }
.preview-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
.preview-btn:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); }
.use-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; border: none; border-radius: 8px; background: var(--card-accent, var(--accent)); color: #1d2021; font-size: 0.72rem; font-weight: 600; cursor: pointer; transition: filter 0.15s ease; }
.use-btn:hover { filter: brightness(1.08); }
.designer-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border-radius: 8px; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
.designer-btn:hover { color: var(--card-accent, var(--accent)); border-color: var(--card-accent, var(--accent)); }
/* Animated "now playing" equalizer */
.now-playing { display: inline-flex; align-items: flex-end; gap: 2px; width: 15px; height: 14px; }
.now-playing i { width: 2.5px; background: currentcolor; border-radius: 1px; transform-origin: bottom; animation: eq 0.9s ease-in-out infinite; }
.now-playing i:nth-child(1) { height: 45%; animation-delay: 0s; }
.now-playing i:nth-child(2) { height: 80%; animation-delay: 0.15s; }
.now-playing i:nth-child(3) { height: 60%; animation-delay: 0.3s; }
.now-playing i:nth-child(4) { height: 95%; animation-delay: 0.45s; }
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .now-playing i { animation: none; } .archetype-card { transition: none; } .archetype-card:hover { transform: none; } }
/* Category chips: lucide icon + label */
.use-case-chips .category-chip svg { flex-shrink: 0; }
.load-more { display: flex; justify-content: center; padding: 12px 0; }
.import-explainer { flex-shrink: 0; padding: 8px 10px; margin-bottom: 8px; background: var(--bg-tertiary); border-radius: 8px; font-size: 0.72rem; color: var(--text-secondary); line-height: 1.4; }
.community-explainer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.submit-actions { display: flex; gap: 6px; flex-shrink: 0; }
.submit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
.submit-btn:hover { border-color: var(--accent); color: var(--accent); }
+128 -22
View File
@@ -8,11 +8,13 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Search, Download, Play, Pause, Trash2, X, Loader, Star,
Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors,
Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors, Store, Send,
} from 'lucide-react';
import { Button, Input } from '../ui';
import { useArchetypeCategories, useArchetypes, useGalleryVoices } from '../api/hooks';
import { useArchetypeCategories, useArchetypes, useGalleryVoices, useCommunityItems } from '../api/hooks';
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
import { addCommunityItem, communitySubmitUrl } from '../api/community';
import { openExternal } from '../api/external';
import {
searchYoutube, downloadYoutubeClip, deleteGalleryVoice,
saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl,
@@ -22,6 +24,7 @@ import { useAppStore } from '../store';
import { apiUrl } from '../api/client';
import { isTauri } from '../utils/media';
import { askConfirm } from '../utils/dialog';
import { ArchetypeAvatar, ArchetypeIcon, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../utils/archetypeIcons';
import './VoiceGallery.css';
const BROWSE_PAGE = 60;
@@ -131,6 +134,9 @@ export default function VoiceGallery() {
<button className={`zone-tab ${zone === 'archetypes' ? 'active' : ''}`} onClick={() => setZone('archetypes')}>
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
</button>
<button className={`zone-tab ${zone === 'community' ? 'active' : ''}`} onClick={() => setZone('community')}>
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
</button>
<button className={`zone-tab ${zone === 'imports' ? 'active' : ''}`} onClick={() => setZone('imports')}>
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
</button>
@@ -167,6 +173,17 @@ export default function VoiceGallery() {
setMode('design');
}}
/>
) : zone === 'community' ? (
<CommunityZone
t={t}
playingId={playingId}
loadingPreviewId={loadingPreviewId}
favorites={favorites}
toggleFavorite={toggleFavorite}
onPlayAudio={(url, id) => playUrl(url, id)}
flash={flash}
onDesign={(instruct) => { setVdStates({ ...vdStates }); setInstruct(instruct); setMode('design'); }}
/>
) : (
<ImportsZone
t={t}
@@ -229,7 +246,7 @@ function ArchetypesZone({
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
title={c.name}
>
<span className="chip-emoji">{c.icon}</span>
<ArchetypeIcon name={c.icon} size={13} />
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
</button>
))}
@@ -315,38 +332,127 @@ function ArchetypeCard({
a, t, viewMode, isFavorite, isPlaying, isLoadingPreview,
onPreview, onUse, onDesign, onToggleFavorite,
}) {
const chips = [a.facets.gender, a.facets.age, a.facets.pitch, a.facets.accent]
.filter(Boolean)
.map((x) => facetLabel(x));
if (a.facets.whisper) chips.push('Whisper');
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
.filter(Boolean).map(facetLabel).join(' · ');
const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
const accentLabel = a.facets.accent
? facetLabel(a.facets.accent)
: (dialect || (a.language === 'Chinese' ? 'Chinese' : null));
return (
<div className={`archetype-card ${viewMode}`}>
<div className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`} style={{ '--card-accent': color }}>
<div className="arch-head">
<ArchetypeAvatar item={a} />
<div className="arch-title">
<div className="archetype-name">{a.name}</div>
{sub && <div className="archetype-sub">{sub}</div>}
</div>
<button
className={`fav-btn ${isFavorite ? 'on' : ''}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
>
<Star size={14} fill={isFavorite ? 'currentColor' : 'none'} />
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button className="archetype-play" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
{isLoadingPreview ? <Loader className="spin" size={18} /> : isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<div className="archetype-body">
<div className="archetype-name"><span className="archetype-icon">{a.icon}</span>{a.name}</div>
</div>
{(accentLabel || a.facets.whisper) && (
<div className="archetype-chips">
{chips.map((c, i) => <span key={i} className="facet-chip">{c}</span>)}
{accentLabel && (
<span className="facet-chip with-flag">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="facet-chip">{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}</span>
)}
</div>
)}
<div className="arch-foot">
<button className="preview-btn" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
{isLoadingPreview ? <Loader className="spin" size={15} /> : isPlaying ? <NowPlaying color={color} /> : <Play size={15} />}
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button className="use-btn" onClick={() => onUse(a)}>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button className="designer-btn" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
<Wand2 size={14} />
</button>
</div>
</div>
<div className="archetype-actions">
<Button size="sm" onClick={() => onUse(a)} title={t('gallery.use_voice', { defaultValue: 'Use voice' })}>
<UserPlus size={13} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</Button>
<Button size="sm" variant="ghost" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
<Wand2 size={13} />
</Button>
);
}
// ── Community zone (marketplace) ─────────────────────────────────────────────
function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) {
const itemsQ = useCommunityItems({ limit: 100 });
const items = itemsQ.data?.items || [];
const favSet = useMemo(() => new Set(favorites), [favorites]);
const submit = async (type) => {
try {
const { url } = await communitySubmitUrl(type);
await openExternal(url);
} catch {
flash(t('gallery.submit_failed', { defaultValue: 'Could not open the submission form.' }));
}
};
return (
<div className="gallery-content gallery-scroll">
<div className="import-explainer community-explainer">
<span>{t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })}</span>
<div className="submit-actions">
<button className="submit-btn" onClick={() => submit('preset')}>
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
</button>
<button className="submit-btn" onClick={() => submit('voice')}>
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
</button>
</div>
</div>
{itemsQ.isLoading ? (
<div className="loading"><Loader className="spin" size={18} /></div>
) : items.length === 0 ? (
<div className="empty">
{t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })}
</div>
) : (
<div className="archetype-grid grid">
{items.map((it) => (
<ArchetypeCard
key={it.id}
a={it}
t={t}
viewMode="grid"
isFavorite={favSet.has(it.id)}
isPlaying={playingId === it.id}
isLoadingPreview={loadingPreviewId === it.id}
onToggleFavorite={toggleFavorite}
onPreview={(item) => (item.audio?.url
? onPlayAudio(item.audio.url, item.id)
: flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))}
onUse={async (item) => {
try {
const r = await addCommunityItem(item.id, item.name);
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name }));
} catch {
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
}
}}
onDesign={(item) => (item.instruct
? onDesign(item.instruct)
: flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))}
/>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import type { StateCreator } from 'zustand';
export type GalleryZone = 'archetypes' | 'imports';
export type GalleryZone = 'archetypes' | 'imports' | 'community';
export interface ArchetypeFilterState {
use_case: string | null;
+95
View File
@@ -0,0 +1,95 @@
// Icon system for the Voice Gallery — lucide SVGs (not emoji, which render
// inconsistently across OSes), country flags for accents, a per-category color
// scale, and a CSS-animated "now playing" equalizer.
import React from 'react';
import {
BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone,
GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull,
Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem,
Music, Lightbulb, Globe,
} from 'lucide-react';
import US from 'country-flag-icons/react/3x2/US';
import GB from 'country-flag-icons/react/3x2/GB';
import AU from 'country-flag-icons/react/3x2/AU';
import CA from 'country-flag-icons/react/3x2/CA';
import IN from 'country-flag-icons/react/3x2/IN';
import CN from 'country-flag-icons/react/3x2/CN';
import JP from 'country-flag-icons/react/3x2/JP';
import KR from 'country-flag-icons/react/3x2/KR';
import PT from 'country-flag-icons/react/3x2/PT';
import RU from 'country-flag-icons/react/3x2/RU';
// lucide component name → component (icon identity comes from the backend).
const ICONS = {
BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone,
GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull,
Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem,
Music, Lightbulb, Globe,
};
// One accent color per use-case (gruvbox palette, matches the app theme).
export const USE_CASE_COLOR = {
narration: '#83a598',
conversational: '#8ec07c',
characters: '#d3869b',
social: '#fe8019',
entertainment: '#fabd2f',
advertisement: '#fb4934',
informative: '#b8bb26',
};
const FLAGS = {
'american accent': US,
'british accent': GB,
'australian accent': AU,
'canadian accent': CA,
'indian accent': IN,
'chinese accent': CN,
'japanese accent': JP,
'korean accent': KR,
'portuguese accent': PT,
'russian accent': RU,
};
function tint(hex, alpha) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
export function ArchetypeIcon({ name, size = 18, color }) {
const Cmp = ICONS[name] || Sparkles;
return <Cmp size={size} color={color} strokeWidth={2} />;
}
/** Country flag for an accent (or the Chinese flag for dialect voices, or a globe). */
export function AccentFlag({ accent, lang, size = 14 }) {
let Flag = accent ? FLAGS[accent] : null;
if (!Flag && lang === 'Chinese') Flag = CN;
if (!Flag) return <Globe size={size} className="flag-globe" />;
return <Flag className="accent-flag" style={{ width: size, height: Math.round(size * 0.75) }} />;
}
/** Animated equalizer shown on the card whose preview is playing (bars in CSS). */
export function NowPlaying({ color }) {
return (
<span className="now-playing" style={color ? { color } : undefined} aria-hidden="true">
<i /><i /><i /><i />
</span>
);
}
/** Color-coded icon tile with a small flag badge — the visual anchor of a card. */
export function ArchetypeAvatar({ item, size = 44 }) {
const color = USE_CASE_COLOR[item.use_case] || '#83a598';
return (
<div
className="arch-avatar"
style={{ width: size, height: size, background: tint(color, 0.14), borderColor: tint(color, 0.32) }}
>
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
<span className="arch-avatar-flag">
<AccentFlag accent={item.facets?.accent} lang={item.language} size={15} />
</span>
</div>
);
}
+1
Submodule omnivoice-gallery added at 22e8e6da80