Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 102 additions & 28 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const els = {
editCancel: document.getElementById("edit-cancel"),
searchInput: document.getElementById("search-input"),
searchMode: document.getElementById("search-mode"),
searchModeBtns: document.querySelectorAll("#search-mode .searchbar__mode-btn"),
semanticSearchInput: document.getElementById("semantic-search-input"),
btnSelectMode: document.getElementById("btn-select-mode"),
selectionBar: document.getElementById("selection-bar"),
Expand Down Expand Up @@ -211,27 +212,53 @@ function clearSharedFilters() {
minRating = 0;
filterFocalMin.value = "";
filterFocalMax.value = "";
ratingFilterEl.value = "0";
els.ratingFilterRejects.value = "0";
activeRatings.clear();
for (let i = 0; i <= 5; i++) activeRatings.add(i);
if (ratingFilterEl) {
ratingFilterEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => { cb.checked = true; });
}
if (els.ratingFilterRejects) renderRejectRatings();
renderFilterDots();
updateFilterButton();
}
// --- Theme (dark / light) — persisted in localStorage, no backend needed ---
// --- Theme (dark / light / liquid-glass) — persisted in localStorage, no backend needed ---
const THEME_KEY = "tiol-theme";
const MENU_BG_KEY = "menu_bg_color";

function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
if (theme === "liquid-glass") {
document.body.classList.add("theme-liquid-glass");
} else {
document.body.classList.remove("theme-liquid-glass");
}
}
function initTheme() {
let saved = "dark";
try { saved = localStorage.getItem(THEME_KEY) || "dark"; } catch (e) {}
applyTheme(saved);
}

async function applyMenuBgColor(color) {
if (!color) return;
document.documentElement.style.setProperty("--menu-bg-custom", color);
try { await invoke("set_setting", { key: MENU_BG_KEY, value: color }); } catch (e) {}
}

async function initMenuBgColor() {
let saved = "#ffffff";
try { saved = (await invoke("get_setting", { key: MENU_BG_KEY })) || "#ffffff"; } catch (e) {}
if (els.menuBgColor) els.menuBgColor.value = saved;
applyMenuBgColor(saved);
}

function renderThemeButtons() {
const cur = document.documentElement.getAttribute("data-theme") || "dark";
els.themeOptions.querySelectorAll("[data-theme]").forEach((btn) => {
btn.classList.toggle("btn--active", btn.dataset.theme === cur);
});
}

els.themeOptions.addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-theme]");
if (!btn) return;
Expand Down Expand Up @@ -270,6 +297,10 @@ els.toggleFxShadow.addEventListener("click", () => {
try { localStorage.setItem(FX_SHADOW_KEY, next); } catch (e) {}
applyFx();
});

els.menuBgColor?.addEventListener("input", (ev) => {
applyMenuBgColor(ev.target.value);
});
els.navPhotos.addEventListener("click", () => {
switchView("photos");
onboardingOnPhotosClicked();
Expand Down Expand Up @@ -311,6 +342,7 @@ async function renderSettings() {
});
renderThemeButtons();
applyFx();
await initMenuBgColor();
renderHwDecode();
detectAndReportRenderer();
refreshModelStatus();
Expand Down Expand Up @@ -1176,12 +1208,16 @@ const COLOR_HEX = {
// The unfiltered result of the current query — filters re-apply to it.
let allPhotos = [];

// Star rating filter (C-17): only show photos with rating >= minRating.
// 0 = no filter. Driven by the #rating-filter dropdown in the search bar.
let minRating = 0;
// Star rating filter (C-17): show only photos whose rating is in the active
// set. Each checkbox toggles one rating (0–5). Unchecked ratings are hidden.
// Default: all ratings selected.
const activeRatings = new Set([0, 1, 2, 3, 4, 5]);
const ratingFilterEl = document.getElementById("rating-filter");
ratingFilterEl.addEventListener("change", () => {
minRating = parseInt(ratingFilterEl.value, 10) || 0;
activeRatings.clear();
ratingFilterEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
if (cb.checked) activeRatings.add(parseInt(cb.value, 10));
});
renderPhotos(applyFilters(allPhotos));
});

Expand All @@ -1191,7 +1227,7 @@ function hasActiveFilters() {
activeLensFilters.size > 0 ||
focalMin != null ||
focalMax != null ||
minRating > 0
activeRatings.size < 6
);
}

Expand All @@ -1217,8 +1253,8 @@ function applyFilters(photos) {
if (focalMin != null && p.focal_length < focalMin) return false;
if (focalMax != null && p.focal_length > focalMax) return false;
}
// Star rating (C-17): photos without a rating (0) fail a >= filter.
if (minRating > 0 && (p.rating || 0) < minRating) return false;
// Star rating (C-17): photo must have a rating currently checked.
if (activeRatings.size < 6 && !activeRatings.has(p.rating || 0)) return false;
return true;
});
}
Expand Down Expand Up @@ -1410,13 +1446,17 @@ document.getElementById("btn-color-filter-clear").addEventListener("click", () =
focalMax = null;
filterFocalMin.value = "";
filterFocalMax.value = "";
minRating = 0;
ratingFilterEl.value = "0";
activeRatings.clear();
for (let i = 0; i <= 5; i++) activeRatings.add(i);
ratingFilterEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.checked = true;
});
renderFilterDots();
renderFilterLens();
updateFilterButton();
renderPhotos(applyFilters(allPhotos));
});

document.addEventListener("click", (e) => {
if (
!colorFilterPanel.hidden &&
Expand Down Expand Up @@ -1735,7 +1775,7 @@ async function setPhotoRating(p, n) {
const stars = p._card.querySelector(".card__stars");
if (stars) renderCardStars(stars, p.rating);
}
if (minRating > 0) renderPhotos(applyFilters(allPhotos));
if (activeRatings.size < 6) renderPhotos(applyFilters(allPhotos));
} catch (e) {
alert(String(e));
}
Expand Down Expand Up @@ -2187,12 +2227,25 @@ function scheduleSearch() {
}
els.searchInput.addEventListener("input", scheduleSearch);
els.semanticSearchInput.addEventListener("input", scheduleSearch);
els.searchMode.addEventListener("change", scheduleSearch);
function setSearchMode(mode) {
els.searchModeBtns.forEach((btn) => {
btn.classList.toggle("searchbar__mode-btn--active", btn.dataset.mode === mode);
});
}

els.searchMode.addEventListener("click", (e) => {
const btn = e.target.closest(".searchbar__mode-btn");
if (!btn) return;
setSearchMode(btn.dataset.mode);
scheduleSearch();
});
// Default to semantic on startup.
setSearchMode("semantic");

async function runSearch() {
const q2 = els.semanticSearchInput.value.trim();
const qName = els.searchInput.value.trim();
const mode = els.searchMode.value;
const mode = els.searchMode.querySelector(".searchbar__mode-btn--active")?.dataset.mode || "semantic";
if (q2) {
try {
const res = await invoke("search", { query: q2, mode });
Expand Down Expand Up @@ -2284,19 +2337,8 @@ async function runRejectSearch() {
}
}

// Rating dropdown of the rejects view shares `minRating` with the photos
// view — keep both selects in sync (C-19).
els.ratingFilterRejects.addEventListener("change", () => {
minRating = parseInt(els.ratingFilterRejects.value, 10) || 0;
ratingFilterEl.value = String(minRating);
if (els.viewRejects.classList.contains("view--hidden")) {
renderPhotos(applyFilters(allPhotos));
} else {
// Reject conditions must still apply — a bare applyFilters would show
// every photo on the rejects page (C-19.11).
renderPhotos(applyRejectConds(applyFilters(allRejects)));
}
});
// Rejects rating filter uses the same activeRatings Set as the photos view.
// The checkbox panel lives inside #reject-cond-panel and mirrors #color-filter.

// Both "Filter" buttons (photos + rejects) open the same global panel,
// anchored under whichever button was clicked.
Expand Down Expand Up @@ -2369,6 +2411,29 @@ listen("reject-analysis-complete", () => {
loadRejects();
});

function readRejectRatings() {
activeRatings.clear();
els.ratingFilterRejects.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
if (cb.checked) activeRatings.add(parseInt(cb.value, 10));
});
}

function renderRejectRatings() {
if (!els.ratingFilterRejects) return;
els.ratingFilterRejects.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.checked = activeRatings.has(parseInt(cb.value, 10));
});
}

els.ratingFilterRejects?.addEventListener("change", () => {
readRejectRatings();
if (els.viewRejects.classList.contains("view--hidden")) {
renderPhotos(applyFilters(allPhotos));
} else {
renderPhotos(applyRejectConds(applyFilters(allRejects)));
}
});

function renderRejectConds() {
els.rejectCondItems.textContent = "";
for (const c of REJECT_CONDS) {
Expand Down Expand Up @@ -2412,6 +2477,10 @@ els.btnRejectCond.addEventListener("click", (e) => {
els.btnRejectCondClear.addEventListener("click", () => {
activeRejectConds.clear();
renderRejectConds();
activeRatings.clear();
for (let i = 0; i <= 5; i++) activeRatings.add(i);
renderRejectRatings();
updateFilterButton();
els.btnRejectCond.classList.remove("searchbar__filter--active");
});
document.addEventListener("click", (e) => {
Expand Down Expand Up @@ -2717,6 +2786,11 @@ function onboardingOnPhotosClicked() {
console.error(e);
}
applyStaticI18n();
// Re-render dynamic i18n content that was created at module-load time with
// the default (en-US) locale before initI18n resolved the saved language.
renderFilterDots();
renderRejectConds();
renderRejectRatings();
initTheme();
applyFx();
// Debug flag gates AI-confidence badges — read it before first render.
Expand Down
60 changes: 35 additions & 25 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,23 @@
<section class="view" id="view-photos">
<div class="searchbar">
<input class="searchbar__input" id="search-input" type="text" data-i18n-placeholder="search.name.placeholder" />
<select class="searchbar__mode" id="search-mode" title="">
<option value="semantic" data-i18n="search.mode.semantic">语义搜索</option>
<option value="tag" data-i18n="search.mode.tag">标签搜索</option>
</select>
<div class="searchbar__mode-segment" id="search-mode">
<button class="btn searchbar__mode-btn" data-mode="semantic" data-i18n="search.mode.semantic">语义搜索</button>
<button class="btn searchbar__mode-btn" data-mode="tag" data-i18n="search.mode.tag">标签搜索</button>
</div>
<input class="searchbar__input" id="semantic-search-input" type="text" data-i18n-placeholder="search.semantic.placeholder" />
<button class="btn btn--ghost searchbar__filter" id="btn-color-filter" data-i18n="photos.filterBtn" data-i18n-title="photos.filterColor">筛选</button>
<select class="searchbar__mode" id="rating-filter" data-i18n-title="photos.ratingFilter">
<option value="0" data-i18n="photos.ratingAll">全部</option>
<option value="1" data-i18n="photos.rating1">≥ 1 星</option>
<option value="2" data-i18n="photos.rating2">≥ 2 星</option>
<option value="3" data-i18n="photos.rating3">≥ 3 星</option>
<option value="4" data-i18n="photos.rating4">≥ 4 星</option>
<option value="5" data-i18n="photos.rating5">≥ 5 星</option>
</select>
<button class="btn searchbar__select" id="btn-select-mode" data-i18n="photos.selectMode">多选</button>
</div>
<div class="grid" id="photo-grid"></div>
<div class="selection-bar glass" id="selection-bar" hidden>
<span class="selection-bar__count" id="selection-count"></span>
<span class="selection-bar__hint" id="selection-hint" hidden></span>
<span class="selection-bar__dots" id="selection-color-dots"></span>
<button class="btn btn--primary" id="btn-selection-tag" data-i18n="photos.addTagSelected" disabled>添加标签</button>
<button class="btn btn--danger" id="btn-selection-clear-tags" data-i18n="photos.deleteTags" disabled>删除标签</button>
<button class="btn btn--ghost" id="btn-selection-cancel" data-i18n="dialog.cancel">取消</button>
</div>
<div class="statusbar" id="photo-status">0 photos</div>
</section>

Expand Down Expand Up @@ -81,14 +81,6 @@
<input class="searchbar__input" id="reject-search-input" type="text" data-i18n-placeholder="search.semantic.placeholder" />
<button class="btn btn--ghost searchbar__filter" id="btn-color-filter-rejects" data-i18n="photos.filterBtn" data-i18n-title="photos.filterColor">筛选</button>
<button class="btn btn--ghost searchbar__filter" id="btn-reject-cond" data-i18n="rejects.cond">废片条件</button>
<select class="searchbar__mode" id="rating-filter-rejects" data-i18n-title="photos.ratingFilter">
<option value="0" data-i18n="photos.ratingAll">全部</option>
<option value="1" data-i18n="photos.rating1">≥ 1 星</option>
<option value="2" data-i18n="photos.rating2">≥ 2 星</option>
<option value="3" data-i18n="photos.rating3">≥ 3 星</option>
<option value="4" data-i18n="photos.rating4">≥ 4 星</option>
<option value="5" data-i18n="photos.rating5">≥ 5 星</option>
</select>
<button class="btn searchbar__select" id="btn-select-mode-rejects" data-i18n="photos.selectMode">多选</button>
</div>
<div class="grid" id="reject-grid"></div>
Expand Down Expand Up @@ -181,7 +173,7 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>

<!-- Image preview (modal, right panel) -->
<div class="preview-overlay" id="preview-overlay" hidden>
<div class="preview" id="preview-panel">
<div class="preview glass" id="preview-panel">
<div class="preview__header">
<button class="preview__close" id="preview-close" data-i18n-title="preview.close" aria-label="Close">✕</button>
<span class="preview__name" id="preview-name"></span>
Expand All @@ -196,7 +188,7 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>

<!-- Tag edit dialog (list of current tags + picker of existing tags) -->
<div class="dialog-overlay" id="edit-overlay" hidden>
<div class="dialog" role="dialog" aria-modal="true">
<div class="dialog glass" role="dialog" aria-modal="true">
<div class="dialog__title" data-i18n="card.edit.title">编辑标签</div>
<div class="dialog__label" data-i18n="card.edit.current">当前标签</div>
<div class="edit-chips" id="edit-chips"></div>
Expand All @@ -212,7 +204,7 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>

<!-- Multi-select "add tag" panel (pick one existing tag for the selection) -->
<div class="dialog-overlay" id="tagpick-overlay" hidden>
<div class="dialog" role="dialog" aria-modal="true">
<div class="dialog glass" role="dialog" aria-modal="true">
<div class="dialog__title" data-i18n="tags.pickTitle">为选中的照片添加标签</div>
<input class="dialog__input" id="tagpick-search" type="text" data-i18n-placeholder="tags.pickSearch" />
<div class="tagpick-list" id="tagpick-list"></div>
Expand All @@ -223,7 +215,7 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>
</div>

<!-- Color/lens/focal filter dropdown (multi-select, all intersect) -->
<div class="color-filter" id="color-filter" hidden>
<div class="color-filter glass" id="color-filter" hidden>
<div class="color-filter__title" data-i18n="photos.filterColor">颜色筛选</div>
<div class="color-filter__dots" id="color-filter-dots"></div>
<div class="color-filter__title" data-i18n="photos.filterLens">镜头</div>
Expand All @@ -234,6 +226,15 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>
<span class="color-filter__focal-sep">–</span>
<input class="color-filter__focal-input" id="filter-focal-max" type="number" min="0" step="1" placeholder="max mm" data-i18n-placeholder="photos.filterFocalMax" />
</div>
<div class="color-filter__title" data-i18n="photos.ratingFilter">按评分筛选</div>
<div class="color-filter__rating" id="rating-filter">
<label><input type="checkbox" value="0" checked><span data-i18n="photos.rating0">无星</span></label>
<label><input type="checkbox" value="1" checked><span data-i18n="photos.rating1">1星</span></label>
<label><input type="checkbox" value="2" checked><span data-i18n="photos.rating2">2星</span></label>
<label><input type="checkbox" value="3" checked><span data-i18n="photos.rating3">3星</span></label>
<label><input type="checkbox" value="4" checked><span data-i18n="photos.rating4">4星</span></label>
<label><input type="checkbox" value="5" checked><span data-i18n="photos.rating5">5星</span></label>
</div>
<button class="btn btn--ghost color-filter__clear" id="btn-color-filter-clear" data-i18n="photos.filterClear">清除</button>
</div>

Expand All @@ -242,6 +243,15 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>
<div class="color-filter reject-cond-panel" id="reject-cond-panel" hidden>
<div class="color-filter__title" data-i18n="rejects.cond">废片条件</div>
<div class="reject-cond__items" id="reject-cond-items"></div>
<div class="color-filter__title" data-i18n="photos.ratingFilter">按评分筛选</div>
<div class="color-filter__rating" id="rating-filter-rejects">
<label><input type="checkbox" value="0" checked><span data-i18n="photos.rating0">无星</span></label>
<label><input type="checkbox" value="1" checked><span data-i18n="photos.rating1">1星</span></label>
<label><input type="checkbox" value="2" checked><span data-i18n="photos.rating2">2星</span></label>
<label><input type="checkbox" value="3" checked><span data-i18n="photos.rating3">3星</span></label>
<label><input type="checkbox" value="4" checked><span data-i18n="photos.rating4">4星</span></label>
<label><input type="checkbox" value="5" checked><span data-i18n="photos.rating5">5星</span></label>
</div>
<button class="btn btn--ghost color-filter__clear" id="btn-reject-cond-clear" data-i18n="rejects.clear">清除</button>
</div>

Expand All @@ -258,7 +268,7 @@ <h2 class="settings__title" data-i18n="settings.title">设置</h2>

<!-- Generic confirm dialog (clears tags, etc.) -->
<div class="dialog-overlay" id="confirm-overlay" hidden>
<div class="dialog" role="dialog" aria-modal="true">
<div class="dialog glass" role="dialog" aria-modal="true">
<div class="dialog__title" data-i18n="dialog.confirmTitle">确认操作</div>
<div class="dialog__text" id="confirm-text"></div>
<div class="dialog__actions">
Expand Down
Loading
Loading