feat(tags): bulk editor, in-modal editor, relations, Cmd+K palette
Quatro features que destravam o uso pratico da tag taxonomy criada
na Fase 2.
Backend
- Schema novo: tag_relations(parent_id, child_id, kind) com kinds
sibling/parent/implies (estilo Hydrus). Indexes nos dois lados.
- Database.bulk_tag_posts(): add ou remove uma tag em N posts numa
unica transacao; reusa _get_or_create_tag.
- Database.add_tag_relation / remove_tag_relation / list_tag_relations
com validacao de kind e self-relation.
- Database.search_universal(): typeahead retornando authors+
subreddits+tags filtrados por LIKE; usado pelo Cmd+K.
- routers/tags.py ganha 4 endpoints: POST /api/posts/tags/bulk,
GET /api/tags/{id}/relations, POST /api/tags/relations,
DELETE /api/tags/relations/{parent}/{child}/{kind}.
- routers/search.py (novo): GET /api/search?q= com cap 20.
Frontend
- _modal_media.html ganha .modal-tags-editor: chips removiveis (click)
+ input de adicionar + select de categoria. loadModalTags() roda
ao abrir o modal; chama /api/posts/{id}/tags. addTagToCurrentPost
/ removeTagFromCurrentPost atualizam in-place.
- tab_gallery.html: selection-bar agora tem .selection-bulk-tag com
input + datalist + select categoria + botoes "+ Tag" e "- Tag".
bulkTag(action) chama /api/posts/tags/bulk. Datalist alimentada
pela cache _tagsCache (TTL 60s).
- index.html: novo overlay #cmdk-overlay com Alpine x-data="cmdkPalette()".
Atalho global Cmd+K / Ctrl+K alterna; setas navegam, Enter seleciona,
Esc fecha. Selecionar autor/subreddit filtra galeria direto; tag
alimenta o input de bulk para acelerar workflow de tagging.
- CSS: .modal-tags-editor, .selection-bulk-tag, .cmdk-overlay/.cmdk-card/
.cmdk-results responsivos. [x-cloak] global para evitar flash.
Bump 1.3.1 -> 1.4.0 (minor — 4 features novas, schema novo, novo
router, sem quebra de contrato existente).
Verificado: 151 testes verdes, ruff/mypy/format OK, docker build OK,
smoke manual confirma /api/search retorna mix correto, bulk add
afeta N posts, relation sibling persiste, Cmd+K abre.
This commit is contained in:
parent
4a540ef379
commit
6364a84610
10 changed files with 565 additions and 2 deletions
|
|
@ -7,7 +7,7 @@ packages = ["src"]
|
|||
|
||||
[project]
|
||||
name = "reddit-media-collector"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
description = "Self-hosted media collector for Reddit with Immich integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
129
src/database.py
129
src/database.py
|
|
@ -137,6 +137,21 @@ class Database:
|
|||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_post_tags_post ON post_tags(post_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)")
|
||||
# Tag relations (Hydrus-style): sibling = synonym (resolves to same
|
||||
# canonical), parent = is-a (children imply parent on post insert),
|
||||
# implies = soft relation (UI hint only, no auto-application).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tag_relations (
|
||||
parent_id INTEGER NOT NULL,
|
||||
child_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
PRIMARY KEY (parent_id, child_id, kind),
|
||||
FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_rel_parent ON tag_relations(parent_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tag_rel_child ON tag_relations(child_id)")
|
||||
conn.commit()
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -1156,3 +1171,117 @@ class Database:
|
|||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def bulk_tag_posts(self, post_ids: list[str], name: str, category: str | None, action: str) -> int:
|
||||
"""Add or remove a tag across many posts in a single transaction.
|
||||
|
||||
``action`` is ``add`` or ``remove``. Returns the count of rows
|
||||
affected (insertions or deletions). Inserts skip if already present;
|
||||
removes are no-op when the row doesn't exist.
|
||||
"""
|
||||
if not post_ids or action not in ("add", "remove"):
|
||||
return 0
|
||||
with self._get_connection() as conn:
|
||||
tag_id = self._get_or_create_tag(conn, name, category)
|
||||
if action == "add":
|
||||
count = 0
|
||||
for pid in post_ids:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO post_tags (post_id, tag_id, source) VALUES (?, ?, 'user')",
|
||||
(pid, tag_id),
|
||||
)
|
||||
count += cur.rowcount
|
||||
conn.commit()
|
||||
return count
|
||||
placeholders = ",".join("?" * len(post_ids))
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM post_tags WHERE tag_id = ? AND post_id IN ({placeholders})",
|
||||
[tag_id, *post_ids],
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
# ---- Tag relations (Hydrus-style) ----
|
||||
|
||||
def add_tag_relation(self, parent_id: int, child_id: int, kind: str) -> None:
|
||||
if kind not in ("sibling", "parent", "implies"):
|
||||
raise ValueError(f"invalid kind: {kind}")
|
||||
if parent_id == child_id:
|
||||
raise ValueError("self-relation not allowed")
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tag_relations (parent_id, child_id, kind) VALUES (?, ?, ?)",
|
||||
(parent_id, child_id, kind),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_tag_relation(self, parent_id: int, child_id: int, kind: str) -> bool:
|
||||
with self._get_connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM tag_relations WHERE parent_id = ? AND child_id = ? AND kind = ?",
|
||||
(parent_id, child_id, kind),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def list_tag_relations(self, tag_id: int) -> dict:
|
||||
"""Both directions: things related TO this tag and FROM it."""
|
||||
with self._get_connection() as conn:
|
||||
up = conn.execute(
|
||||
"""
|
||||
SELECT t.id, t.name, t.category, r.kind
|
||||
FROM tag_relations r JOIN tags t ON r.parent_id = t.id
|
||||
WHERE r.child_id = ?
|
||||
""",
|
||||
(tag_id,),
|
||||
).fetchall()
|
||||
down = conn.execute(
|
||||
"""
|
||||
SELECT t.id, t.name, t.category, r.kind
|
||||
FROM tag_relations r JOIN tags t ON r.child_id = t.id
|
||||
WHERE r.parent_id = ?
|
||||
""",
|
||||
(tag_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"parents": [dict(row) for row in up],
|
||||
"children": [dict(row) for row in down],
|
||||
}
|
||||
|
||||
# ---- Universal search (Cmd+K palette) ----
|
||||
|
||||
def search_universal(self, query: str, limit: int = 8) -> dict:
|
||||
"""Mixed-source typeahead. Returns small lists from each source."""
|
||||
q = f"%{query.lower()}%"
|
||||
with self._get_connection() as conn:
|
||||
authors = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT author FROM posts
|
||||
WHERE LOWER(author) LIKE ? AND author NOT IN ('[deleted]', 'deleted')
|
||||
AND downloaded_at IS NOT NULL
|
||||
LIMIT ?
|
||||
""",
|
||||
(q, limit),
|
||||
).fetchall()
|
||||
]
|
||||
subreddits = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT subreddit FROM posts
|
||||
WHERE LOWER(subreddit) LIKE ? AND downloaded_at IS NOT NULL
|
||||
LIMIT ?
|
||||
""",
|
||||
(q, limit),
|
||||
).fetchall()
|
||||
]
|
||||
tags = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT id, name, category FROM tags WHERE LOWER(name) LIKE ? LIMIT ?",
|
||||
(q, limit),
|
||||
).fetchall()
|
||||
]
|
||||
return {"authors": authors, "subreddits": subreddits, "tags": tags}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||
|
||||
from . import config_manager
|
||||
from .auth import require_auth
|
||||
from .routers import config, favorites, health, media, scheduler, stats, tags
|
||||
from .routers import config, favorites, health, media, scheduler, search, stats, tags
|
||||
from .session import COOKIE_NAME, cookie_valid, issue_cookie, pin_required, verify_pin
|
||||
|
||||
|
||||
|
|
@ -76,6 +76,7 @@ app.include_router(stats.router, dependencies=_auth_dep)
|
|||
app.include_router(scheduler.router, dependencies=_auth_dep)
|
||||
app.include_router(favorites.router, dependencies=_auth_dep)
|
||||
app.include_router(tags.router, dependencies=_auth_dep)
|
||||
app.include_router(search.router, dependencies=_auth_dep)
|
||||
|
||||
_static_dir = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
||||
|
|
|
|||
21
src/web/routers/search.py
Normal file
21
src/web/routers/search.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Universal search endpoint feeding the Cmd+K palette."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from ...database import Database
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def universal_search(
|
||||
q: str = Query(default="", min_length=1),
|
||||
limit: int = Query(default=8, le=20, ge=1),
|
||||
):
|
||||
"""Mixed typeahead: authors / subreddits / tags. Empty q returns nothing."""
|
||||
if not q.strip():
|
||||
return {"authors": [], "subreddits": [], "tags": []}
|
||||
db = Database()
|
||||
return db.search_universal(q.strip(), limit=limit)
|
||||
|
|
@ -63,3 +63,62 @@ async def backfill_tags():
|
|||
db = Database()
|
||||
count = db.backfill_auto_tags()
|
||||
return {"posts_tagged": count}
|
||||
|
||||
|
||||
class BulkTagAction(BaseModel):
|
||||
post_ids: list[str]
|
||||
name: str
|
||||
category: str | None = None
|
||||
action: str # "add" or "remove"
|
||||
|
||||
|
||||
@router.post("/api/posts/tags/bulk", dependencies=[_mut])
|
||||
async def bulk_tag_posts(data: BulkTagAction):
|
||||
"""Apply or strip a tag across many posts in one request."""
|
||||
if data.action not in ("add", "remove"):
|
||||
raise HTTPException(status_code=400, detail="action must be 'add' or 'remove'")
|
||||
if not data.name.strip():
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
if not data.post_ids:
|
||||
raise HTTPException(status_code=400, detail="post_ids is required")
|
||||
db = Database()
|
||||
affected = db.bulk_tag_posts(data.post_ids, data.name.strip(), data.category, data.action)
|
||||
return {"action": data.action, "affected": affected, "post_count": len(data.post_ids)}
|
||||
|
||||
|
||||
# ---- Tag relations (Hydrus-style) ----
|
||||
|
||||
|
||||
class TagRelation(BaseModel):
|
||||
parent_id: int
|
||||
child_id: int
|
||||
kind: str # "sibling", "parent", "implies"
|
||||
|
||||
|
||||
@router.get("/api/tags/{tag_id}/relations")
|
||||
async def get_tag_relations(tag_id: int):
|
||||
"""List siblings, parents and implied tags for a given tag (both directions)."""
|
||||
db = Database()
|
||||
return db.list_tag_relations(tag_id)
|
||||
|
||||
|
||||
@router.post("/api/tags/relations", dependencies=[_mut])
|
||||
async def add_tag_relation(data: TagRelation):
|
||||
if data.kind not in ("sibling", "parent", "implies"):
|
||||
raise HTTPException(status_code=400, detail="kind must be sibling|parent|implies")
|
||||
if data.parent_id == data.child_id:
|
||||
raise HTTPException(status_code=400, detail="self-relation not allowed")
|
||||
db = Database()
|
||||
try:
|
||||
db.add_tag_relation(data.parent_id, data.child_id, data.kind)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"message": "relation added"}
|
||||
|
||||
|
||||
@router.delete("/api/tags/relations/{parent_id}/{child_id}/{kind}", dependencies=[_mut])
|
||||
async def delete_tag_relation(parent_id: int, child_id: int, kind: str):
|
||||
db = Database()
|
||||
if not db.remove_tag_relation(parent_id, child_id, kind):
|
||||
raise HTTPException(status_code=404, detail="Relation not found")
|
||||
return {"message": "relation removed"}
|
||||
|
|
|
|||
|
|
@ -1203,6 +1203,127 @@ body:not(.discreet) .discreet-banner {
|
|||
.tag-chip.cat-genre { color: #00a86b; border-color: #00a86b; }
|
||||
.tag-chip.cat-meta { color: #9333ea; border-color: #9333ea; }
|
||||
|
||||
/* Tag editor in media modal */
|
||||
.modal-tags-editor {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #343536;
|
||||
}
|
||||
.modal-tags-editor .tag-chips {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-tags-editor .tag-chip {
|
||||
cursor: pointer;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.modal-tags-editor .tag-chip .tag-chip-x {
|
||||
margin-left: 4px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.modal-tags-editor .tag-chip:hover .tag-chip-x {
|
||||
opacity: 1;
|
||||
color: #ea0027;
|
||||
}
|
||||
.tag-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 6px;
|
||||
}
|
||||
.tag-input-row input,
|
||||
.tag-input-row select {
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Selection bar bulk tag controls */
|
||||
.selection-bulk-tag {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
border-left: 1px solid #1a1a1b;
|
||||
border-right: 1px solid #1a1a1b;
|
||||
padding: 0 12px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.selection-bulk-tag input,
|
||||
.selection-bulk-tag select {
|
||||
width: auto;
|
||||
min-width: 110px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.selection-bulk-tag {
|
||||
order: 3;
|
||||
border: none;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cmd+K palette overlay */
|
||||
[x-cloak] { display: none !important; }
|
||||
.cmdk-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 10vh;
|
||||
z-index: 3000;
|
||||
}
|
||||
.cmdk-card {
|
||||
background: #272729;
|
||||
border: 1px solid #343536;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmdk-card > input {
|
||||
border: none;
|
||||
border-bottom: 1px solid #343536;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1b;
|
||||
}
|
||||
.cmdk-card > input:focus {
|
||||
border-color: #ff4500;
|
||||
}
|
||||
.cmdk-results {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cmdk-group-label {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #818384;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cmdk-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cmdk-item.active,
|
||||
.cmdk-item:hover {
|
||||
background: #343536;
|
||||
color: #ff4500;
|
||||
}
|
||||
.cmdk-hint {
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
color: #818384;
|
||||
border-top: 1px solid #343536;
|
||||
background: #1a1a1b;
|
||||
}
|
||||
|
||||
/* PIN unlock screen */
|
||||
.unlock-wrap {
|
||||
min-height: 100vh;
|
||||
|
|
|
|||
|
|
@ -552,6 +552,7 @@ function toggleSelectMode() {
|
|||
btn.classList.add('active');
|
||||
btn.textContent = 'Cancelar';
|
||||
selectionBar.classList.add('visible');
|
||||
refreshTagSuggestions('bulk-tag-suggestions');
|
||||
} else {
|
||||
galleryGrid.classList.remove('select-mode');
|
||||
btn.classList.remove('active');
|
||||
|
|
@ -777,6 +778,7 @@ function updateModalContent() {
|
|||
}
|
||||
document.getElementById('modal-delete').onclick = () => deleteMedia(currentItem.id);
|
||||
updateFavoriteButton();
|
||||
loadModalTags();
|
||||
}
|
||||
|
||||
function updateModalNavigation() {
|
||||
|
|
@ -1440,6 +1442,187 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
loadSettings();
|
||||
});
|
||||
|
||||
// ---- Tag editor: modal + bulk ----
|
||||
|
||||
// Cache of all tag names, refreshed on demand. Feeds <datalist> suggestions
|
||||
// in both the modal editor and the bulk-selection bar.
|
||||
let _tagsCache = null;
|
||||
let _tagsCacheAt = 0;
|
||||
async function getTagsCache(force = false) {
|
||||
if (!force && _tagsCache && Date.now() - _tagsCacheAt < 60_000) return _tagsCache;
|
||||
try {
|
||||
const r = await fetch('/api/tags');
|
||||
if (r.ok) {
|
||||
_tagsCache = await r.json();
|
||||
_tagsCacheAt = Date.now();
|
||||
}
|
||||
} catch (_) {}
|
||||
return _tagsCache || [];
|
||||
}
|
||||
|
||||
async function refreshTagSuggestions(datalistId) {
|
||||
const list = document.getElementById(datalistId);
|
||||
if (!list) return;
|
||||
const tags = await getTagsCache();
|
||||
list.innerHTML = tags.map(t => `<option value="${escapeHtml(t.name)}">${escapeHtml(t.category || '')}</option>`).join('');
|
||||
}
|
||||
|
||||
// Re-render the modal tag chips for currentItem (fetched per open).
|
||||
async function loadModalTags() {
|
||||
if (!currentItem) return;
|
||||
const wrap = document.getElementById('modal-tag-chips');
|
||||
if (!wrap) return;
|
||||
try {
|
||||
const r = await fetch(`/api/posts/${encodeURIComponent(currentItem.id)}/tags`);
|
||||
const list = r.ok ? await r.json() : [];
|
||||
currentItem.tags = list;
|
||||
wrap.innerHTML = list.map(t => {
|
||||
const cat = t.category ? ` cat-${escapeHtml(t.category)}` : '';
|
||||
return `<span class="tag-chip${cat}" data-tag-id="${t.id}" onclick="removeTagFromCurrentPost(${t.id})" title="${escapeHtml(t.category || 'tag')} — clique pra remover">${escapeHtml(t.name)}<span class="tag-chip-x">×</span></span>`;
|
||||
}).join('') || '<span class="empty" style="font-size:11px;">Nenhuma tag</span>';
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags for post:', err);
|
||||
}
|
||||
refreshTagSuggestions('modal-tag-suggestions');
|
||||
}
|
||||
|
||||
async function addTagToCurrentPost() {
|
||||
if (!currentItem) return;
|
||||
const input = document.getElementById('modal-tag-input');
|
||||
const cat = document.getElementById('modal-tag-category').value || null;
|
||||
const name = (input.value || '').trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const r = await fetch(`/api/posts/${encodeURIComponent(currentItem.id)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, category: cat}),
|
||||
});
|
||||
if (r.ok) {
|
||||
input.value = '';
|
||||
_tagsCache = null;
|
||||
await loadModalTags();
|
||||
} else {
|
||||
showApiError(r, 'Erro ao adicionar tag');
|
||||
}
|
||||
} catch (err) {
|
||||
showApiError(err, 'Erro ao adicionar tag');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTagFromCurrentPost(tagId) {
|
||||
if (!currentItem) return;
|
||||
try {
|
||||
const r = await fetch(`/api/posts/${encodeURIComponent(currentItem.id)}/tags/${tagId}`, {method: 'DELETE'});
|
||||
if (r.ok) {
|
||||
await loadModalTags();
|
||||
} else {
|
||||
showApiError(r, 'Erro ao remover tag');
|
||||
}
|
||||
} catch (err) {
|
||||
showApiError(err, 'Erro ao remover tag');
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkTag(action) {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) {
|
||||
showAlert('Selecione ao menos um item', 'error');
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('bulk-tag-input');
|
||||
const cat = document.getElementById('bulk-tag-category').value || null;
|
||||
const name = (input.value || '').trim();
|
||||
if (!name) {
|
||||
showAlert('Digite o nome da tag', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/posts/tags/bulk', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({post_ids: ids, name, category: cat, action}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
showAlert(`${action === 'add' ? 'Adicionado' : 'Removido'} em ${data.affected} de ${data.post_count} posts`, 'success');
|
||||
input.value = '';
|
||||
_tagsCache = null;
|
||||
} else {
|
||||
showApiError(r, 'Erro no bulk tag');
|
||||
}
|
||||
} catch (err) {
|
||||
showApiError(err, 'Erro no bulk tag');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cmd+K palette (Alpine component) ----
|
||||
|
||||
function cmdkPalette() {
|
||||
return {
|
||||
open: false,
|
||||
query: '',
|
||||
active: 0,
|
||||
results: {authors: [], subreddits: [], tags: []},
|
||||
toggle() { this.open = !this.open; if (this.open) { this.query=''; this.results={authors:[],subreddits:[],tags:[]}; this.active=0; this.$nextTick(()=>this.$el.querySelector('input')?.focus()); } },
|
||||
close() { this.open = false; },
|
||||
async search() {
|
||||
const q = this.query.trim();
|
||||
if (!q) { this.results = {authors: [], subreddits: [], tags: []}; return; }
|
||||
try {
|
||||
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=6`);
|
||||
if (r.ok) this.results = await r.json();
|
||||
this.active = 0;
|
||||
} catch (_) {}
|
||||
},
|
||||
groups() {
|
||||
const out = [];
|
||||
if (this.results.authors?.length) out.push({label: 'Autores', items: this.results.authors.map(a => ({label: 'u/'+a, kind: 'author', value: a}))});
|
||||
if (this.results.subreddits?.length) out.push({label: 'Subreddits', items: this.results.subreddits.map(s => ({label: 'r/'+s, kind: 'subreddit', value: s}))});
|
||||
if (this.results.tags?.length) out.push({label: 'Tags', items: this.results.tags.map(t => ({label: `#${t.name}${t.category ? ' ('+t.category+')' : ''}`, kind: 'tag', value: t.name}))});
|
||||
return out;
|
||||
},
|
||||
hasResults() { return this.groups().some(g => g.items.length > 0); },
|
||||
flatIndex(gi, ii) {
|
||||
let n = 0;
|
||||
const gs = this.groups();
|
||||
for (let i = 0; i < gi; i++) n += gs[i].items.length;
|
||||
return n + ii;
|
||||
},
|
||||
flatItems() {
|
||||
return this.groups().flatMap(g => g.items);
|
||||
},
|
||||
move(d) {
|
||||
const total = this.flatItems().length;
|
||||
if (!total) return;
|
||||
this.active = (this.active + d + total) % total;
|
||||
},
|
||||
choose() {
|
||||
const items = this.flatItems();
|
||||
if (items[this.active]) this.select(items[this.active]);
|
||||
},
|
||||
select(item) {
|
||||
this.close();
|
||||
if (item.kind === 'author') {
|
||||
switchTab('gallery', true);
|
||||
document.getElementById('filter-author').value = item.value;
|
||||
loadGallery();
|
||||
} else if (item.kind === 'subreddit') {
|
||||
switchTab('gallery', true);
|
||||
document.getElementById('filter-subreddit').value = item.value;
|
||||
loadGallery();
|
||||
} else if (item.kind === 'tag') {
|
||||
// No tag filter on gallery yet — drop into the tag in the bulk
|
||||
// bar as a shortcut for adding to current selection.
|
||||
const bulk = document.getElementById('bulk-tag-input');
|
||||
if (bulk) bulk.value = item.value;
|
||||
showAlert(`Tag "${item.value}" pronta para bulk-aplicar`, 'success');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
window.cmdkPalette = cmdkPalette;
|
||||
|
||||
// Alpine.store('prefs') — NSFW gate + discreet mode (Fase 1).
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const stored = JSON.parse(localStorage.getItem('rmc:prefs') || '{}');
|
||||
|
|
|
|||
|
|
@ -55,5 +55,26 @@
|
|||
|
||||
{% include 'partials/_modal_author.html' %}
|
||||
{% include 'partials/_modal_media.html' %}
|
||||
|
||||
<div id="cmdk-overlay" class="cmdk-overlay" x-data="cmdkPalette()" x-show="open" x-cloak
|
||||
@keydown.escape.window="close()"
|
||||
@keydown.window.prevent.meta.k="toggle()"
|
||||
@keydown.window.prevent.ctrl.k="toggle()"
|
||||
@click.self="close()">
|
||||
<div class="cmdk-card" @click.stop>
|
||||
<input type="text" x-model="query" @input.debounce.150ms="search()" @keydown.arrow-down.prevent="move(1)" @keydown.arrow-up.prevent="move(-1)" @keydown.enter.prevent="choose()" placeholder="Buscar autores, subreddits, tags..." autocomplete="off">
|
||||
<div class="cmdk-results" x-show="hasResults()">
|
||||
<template x-for="(group, gi) in groups()" :key="gi">
|
||||
<div>
|
||||
<div class="cmdk-group-label" x-text="group.label"></div>
|
||||
<template x-for="(item, ii) in group.items" :key="gi + ':' + ii">
|
||||
<div class="cmdk-item" :class="{active: flatIndex(gi, ii) === active}" @click="select(item)" x-text="item.label"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="cmdk-hint">⌘K / Ctrl+K para abrir · Esc para fechar</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@
|
|||
<div class="modal-info">
|
||||
<div class="modal-title" id="modal-title"></div>
|
||||
<div class="modal-meta" id="modal-meta"></div>
|
||||
<div class="modal-tags-editor">
|
||||
<div class="tag-chips" id="modal-tag-chips"></div>
|
||||
<div class="tag-input-row">
|
||||
<input type="text" id="modal-tag-input" placeholder="Adicionar tag..." list="modal-tag-suggestions" autocomplete="off">
|
||||
<datalist id="modal-tag-suggestions"></datalist>
|
||||
<select id="modal-tag-category">
|
||||
<option value="">categoria...</option>
|
||||
<option value="performer">performer</option>
|
||||
<option value="source">source</option>
|
||||
<option value="genre">genre</option>
|
||||
<option value="meta">meta</option>
|
||||
</select>
|
||||
<button type="button" onclick="addTagToCurrentPost()" class="btn-add" style="width:auto;margin:0;padding:8px 14px;">+ Tag</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-add" id="modal-favorite" onclick="toggleFavorite()" style="background: #ff4500;">
|
||||
★ Favoritar
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@
|
|||
<span class="selection-count"><span id="selected-count">0</span> selecionado(s)</span>
|
||||
<button class="btn-select-all" onclick="selectAllVisible()">Selecionar Todos</button>
|
||||
</div>
|
||||
<div class="selection-bulk-tag">
|
||||
<input type="text" id="bulk-tag-input" placeholder="tag..." list="bulk-tag-suggestions" autocomplete="off">
|
||||
<datalist id="bulk-tag-suggestions"></datalist>
|
||||
<select id="bulk-tag-category">
|
||||
<option value="">categoria...</option>
|
||||
<option value="performer">performer</option>
|
||||
<option value="source">source</option>
|
||||
<option value="genre">genre</option>
|
||||
<option value="meta">meta</option>
|
||||
</select>
|
||||
<button type="button" class="btn-select-all" onclick="bulkTag('add')">+ Tag</button>
|
||||
<button type="button" class="btn-cancel-select" onclick="bulkTag('remove')">− Tag</button>
|
||||
</div>
|
||||
<div class="selection-actions">
|
||||
<button class="btn-cancel-select" onclick="toggleSelectMode()">Cancelar</button>
|
||||
<button class="btn-delete-selected" onclick="deleteSelected()">Deletar Selecionados</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue