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:
authentik Default Admin 2026-05-17 22:10:59 +01:00
parent 4a540ef379
commit 6364a84610
10 changed files with 565 additions and 2 deletions

View file

@ -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"

View file

@ -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}

View file

@ -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
View 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)

View file

@ -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"}

View file

@ -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;

View file

@ -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">&times;</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') || '{}');

View file

@ -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 &middot; Esc para fechar</div>
</div>
</div>
</body>
</html>

View file

@ -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;">
&#9733; Favoritar

View file

@ -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')">&minus; 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>