feat: add darktable web API interface with gallery, image detail, and settings screens
- Implemented main entry point for React application in `main.jsx`. - Created `Gallery` component for displaying images with infinite scroll and pull-to-refresh functionality. - Developed `ImageDetail` component for showing detailed information and actions for selected images. - Added `Settings` component for configuring API connection settings. - Integrated Vite for build and development with PWA support. - Implemented caching strategies for API responses and image previews using Workbox.
This commit is contained in:
@@ -210,7 +210,15 @@ function M.get_preview(req)
|
||||
end
|
||||
end
|
||||
|
||||
return err(404, "no_preview", "no cached preview available for image " .. id)
|
||||
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
|
||||
-- so the bridge renders one; the client should retry after a few seconds.
|
||||
enqueue_refresh(id)
|
||||
return 202, json.encode({
|
||||
status = "generating",
|
||||
image_id = id,
|
||||
retry_after = 4,
|
||||
message = "thumbnail is being generated; retry in a few seconds",
|
||||
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
|
||||
end
|
||||
|
||||
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
@@ -410,9 +418,8 @@ end
|
||||
|
||||
-- ── Full-resolution export ────────────────────────────────────────────────────
|
||||
-- POST /api/v1/images/:id/export
|
||||
-- Enqueues a full-resolution JPEG export via the darktable bridge.
|
||||
-- The bridge calls dt.new_format("jpeg"):write_image() which runs the complete
|
||||
-- development pipeline. Returns 202 immediately; poll GET /full to check when ready.
|
||||
-- Removes any stale export file, enqueues the bridge job, returns 202.
|
||||
-- The client should poll GET /full until it receives a 200.
|
||||
-- Requires scope: cache:refresh
|
||||
function M.trigger_export(req)
|
||||
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
||||
@@ -424,17 +431,17 @@ function M.trigger_export(req)
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
-- Remove stale export so the client can detect when the fresh one lands.
|
||||
os.remove(export_path(id))
|
||||
|
||||
local queued, qerr = enqueue_export(id)
|
||||
if not queued then
|
||||
return err(500, "queue_error", qerr)
|
||||
end
|
||||
if not queued then return err(500, "queue_error", qerr) end
|
||||
|
||||
return 202, json.encode({
|
||||
status = "accepted",
|
||||
image_id = id,
|
||||
filename = img.filename,
|
||||
export = export_path(id),
|
||||
message = "full-resolution export enqueued; poll GET /api/v1/images/" .. id .. "/full when ready",
|
||||
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
|
||||
}), "application/json; charset=utf-8"
|
||||
end
|
||||
|
||||
@@ -455,9 +462,10 @@ function M.get_full(req)
|
||||
local path = export_path(id)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
return 200, data, "image/jpeg"
|
||||
local data = f:read("*a"); f:close()
|
||||
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
|
||||
local disposition = string.format('attachment; filename="%s.jpg"', stem)
|
||||
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
|
||||
end
|
||||
|
||||
-- Not yet exported — enqueue and tell the client to retry.
|
||||
|
||||
@@ -92,17 +92,58 @@ local function drain_queue()
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ── Mipmap directory ─────────────────────────────────────────────────────────
|
||||
|
||||
-- Darktable's mipmap_cache->cachedir is not directly accessible from Lua, so
|
||||
-- we replicate the naming: SHA1 hex of realpath(library.db) + ".d".
|
||||
-- We compute it once and cache it here.
|
||||
local _mipmap_cachedir
|
||||
|
||||
|
||||
local function mipmap_cachedir()
|
||||
if _mipmap_cachedir then return _mipmap_cachedir end
|
||||
local cache_dir = os.getenv("DT_CACHE_DIR")
|
||||
if not cache_dir then
|
||||
local home = os.getenv("HOME") or "~"
|
||||
cache_dir = home .. "/.cache/darktable"
|
||||
end
|
||||
-- There is exactly one mipmaps-<sha1>.d directory per library.
|
||||
-- Find it by globbing rather than recomputing the SHA1.
|
||||
local h = io.popen(string.format("ls -d %q/mipmaps-*.d 2>/dev/null | head -1", cache_dir))
|
||||
local found = h and h:read("*l"); if h then h:close() end
|
||||
if found and #found > 0 then
|
||||
-- Strip trailing ".d" to get the base prefix used in file paths.
|
||||
_mipmap_cachedir = found:match("^(.+)%.d$") or found
|
||||
log("mipmap cachedir: " .. _mipmap_cachedir .. ".d")
|
||||
return _mipmap_cachedir
|
||||
end
|
||||
log("WARNING: could not find mipmap cache directory under " .. cache_dir)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Delete all mipmap disk files for an image so generate_cache regenerates them.
|
||||
local function delete_mipmap_files(imgid)
|
||||
local base = mipmap_cachedir()
|
||||
if not base then return end
|
||||
for level = 0, 8 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, imgid)
|
||||
os.remove(path)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Cache regeneration ────────────────────────────────────────────────────────
|
||||
|
||||
local function refresh_image(img)
|
||||
local label = string.format("id=%d %s/%s", img.id, img.path, img.filename)
|
||||
log("refreshing " .. label)
|
||||
|
||||
-- Remove disk mipmap files first so generate_cache doesn't skip any level.
|
||||
-- drop_cache() only clears the in-memory entry; existing disk files would
|
||||
-- cause generate_cache to skip those levels (it guards with dt_util_test_image_file).
|
||||
delete_mipmap_files(img.id)
|
||||
img:drop_cache()
|
||||
|
||||
-- full=true forces darktable to re-process through its complete rendering
|
||||
-- pipeline (tone curves, exposure, colour grading, etc.) instead of using
|
||||
-- the stale embedded EXIF thumbnail from the source file.
|
||||
-- create_dirs=true ensures the per-level directories exist.
|
||||
img:generate_cache(true, MIP_MIN, MIP_MAX)
|
||||
|
||||
log("done " .. label)
|
||||
|
||||
@@ -458,6 +458,7 @@ function M.get_module_blob(imgid, operation)
|
||||
end
|
||||
|
||||
-- Return the single active history entry for one operation.
|
||||
function M.get_module(imgid, operation)
|
||||
local row = query_one([[
|
||||
SELECT h.operation, h.module, h.enabled, h.op_params,
|
||||
h.multi_name, h.multi_priority, h.num
|
||||
|
||||
@@ -79,13 +79,21 @@ end
|
||||
function M.get(image_id, image_path, filename, size_param)
|
||||
local mip = size_to_mip(size_param)
|
||||
|
||||
-- 1. Walk mip levels downward until we find a cached JPEG.
|
||||
-- 1. Walk mip levels: first downward (smaller = smaller file, faster),
|
||||
-- then upward (larger mip levels, let the browser scale down).
|
||||
-- This ensures we serve something even when only higher-resolution
|
||||
-- mipmaps have been cached (e.g. darktable only wrote levels 2-4).
|
||||
local base = mipmap_base()
|
||||
for level = mip, 0, -1 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
||||
local data = read_file(path)
|
||||
if data then return data, "image/jpeg" end
|
||||
end
|
||||
for level = mip + 1, 5 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
||||
local data = read_file(path)
|
||||
if data then return data, "image/jpeg" end
|
||||
end
|
||||
|
||||
-- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file.
|
||||
if image_path and filename then
|
||||
|
||||
@@ -30,6 +30,56 @@ local config = require "config"
|
||||
local router = require "router"
|
||||
local api = require "api"
|
||||
|
||||
-- ── Static file server (PWA) ──────────────────────────────────────────────────
|
||||
|
||||
local WEB_BUILD = script_dir .. "/web/build"
|
||||
|
||||
local MIME = {
|
||||
html = "text/html; charset=utf-8",
|
||||
js = "application/javascript; charset=utf-8",
|
||||
mjs = "application/javascript; charset=utf-8",
|
||||
css = "text/css; charset=utf-8",
|
||||
json = "application/json",
|
||||
webmanifest = "application/manifest+json",
|
||||
png = "image/png",
|
||||
jpg = "image/jpeg",
|
||||
jpeg = "image/jpeg",
|
||||
svg = "image/svg+xml",
|
||||
ico = "image/x-icon",
|
||||
woff = "font/woff",
|
||||
woff2 = "font/woff2",
|
||||
txt = "text/plain; charset=utf-8",
|
||||
map = "application/json",
|
||||
}
|
||||
|
||||
-- Read a file from the web build directory. Returns data, content-type or nil.
|
||||
-- Prevents directory traversal by rejecting any path containing "..".
|
||||
local function read_static(req_path)
|
||||
if req_path:find("%.%.", 1, true) then return nil end
|
||||
|
||||
-- Strip leading slash; normalise double-slashes.
|
||||
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
|
||||
local full = WEB_BUILD .. "/" .. rel
|
||||
|
||||
local f = io.open(full, "rb")
|
||||
if not f then return nil end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
|
||||
local ext = rel:match("%.([^%.]+)$") or ""
|
||||
local ct = MIME[ext:lower()] or "application/octet-stream"
|
||||
return data, ct
|
||||
end
|
||||
|
||||
-- Cache headers: hashed assets get long TTL; everything else no-store.
|
||||
local function cache_header(path)
|
||||
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
|
||||
if path:match("/assets/") then
|
||||
return "public, max-age=31536000, immutable"
|
||||
end
|
||||
return "no-store"
|
||||
end
|
||||
|
||||
-- ── Register routes ───────────────────────────────────────────────────────────
|
||||
|
||||
router.post("/oauth/token", api.oauth_token)
|
||||
@@ -164,16 +214,32 @@ local function handle_client(client)
|
||||
return
|
||||
end
|
||||
|
||||
-- API / OAuth routes take priority over static files.
|
||||
local handler, path_params = router.match(req.method, req.path)
|
||||
if not handler then
|
||||
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
|
||||
if handler then
|
||||
req.path_params = path_params or {}
|
||||
local status, body, ct, extra = handler(req)
|
||||
write_response(client, status, body, ct, extra)
|
||||
return
|
||||
end
|
||||
|
||||
req.path_params = path_params or {}
|
||||
-- Static file serving for the PWA (GET/HEAD only).
|
||||
if req.method == "GET" or req.method == "HEAD" then
|
||||
local data, ct = read_static(req.path)
|
||||
|
||||
local status, body, ct, extra = handler(req)
|
||||
write_response(client, status, body, ct, extra)
|
||||
if not data then
|
||||
-- SPA fallback: any unknown path gets index.html so client-side routing works.
|
||||
data, ct = read_static("/index.html")
|
||||
end
|
||||
|
||||
if data then
|
||||
local extra = { ["Cache-Control"] = cache_header(req.path) }
|
||||
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
|
||||
end
|
||||
|
||||
-- ── Main server loop ──────────────────────────────────────────────────────────
|
||||
@@ -192,8 +258,9 @@ server:settimeout(0) -- non-blocking accept
|
||||
io.write(string.format(
|
||||
"darktable-api: listening on http://%s:%d\n" ..
|
||||
" library : %s\n" ..
|
||||
" cache : %s\n",
|
||||
host, port, config.library_db, config.cache_dir))
|
||||
" cache : %s\n" ..
|
||||
" web app : http://%s:%d/\n",
|
||||
host, port, config.library_db, config.cache_dir, host, port))
|
||||
io.flush()
|
||||
|
||||
while true do
|
||||
|
||||
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.vite/
|
||||
19
contrib/darktable-api/web/index.html
Normal file
19
contrib/darktable-api/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<meta name="description" content="darktable REST API web interface" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="darktable" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<title>darktable Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
contrib/darktable-api/web/package.json
Normal file
20
contrib/darktable-api/web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "darktable-api-pwa",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
28
contrib/darktable-api/web/public/icon.svg
Normal file
28
contrib/darktable-api/web/public/icon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!-- Dark background -->
|
||||
<rect width="512" height="512" rx="80" fill="#0d0d0d"/>
|
||||
<!-- Aperture blades in orange accent -->
|
||||
<!-- Center circle -->
|
||||
<circle cx="256" cy="256" r="60" fill="none" stroke="#f4a22d" stroke-width="8"/>
|
||||
<!-- Aperture blade 1 -->
|
||||
<path d="M256 120 L296 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 2 -->
|
||||
<path d="M370 181 L316 240 L296 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 3 -->
|
||||
<path d="M392 296 L316 240 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 4 -->
|
||||
<path d="M336 392 L256 316 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 5 -->
|
||||
<path d="M176 392 L196 316 L256 316 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 6 -->
|
||||
<path d="M120 296 L196 316 L176 240 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 7 -->
|
||||
<path d="M120 181 L176 240 L196 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 8 -->
|
||||
<path d="M216 120 L216 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Outer ring -->
|
||||
<circle cx="256" cy="256" r="180" fill="none" stroke="#f4a22d" stroke-width="12" opacity="0.6"/>
|
||||
<!-- Inner highlight -->
|
||||
<circle cx="256" cy="256" r="40" fill="#f4a22d" opacity="0.3"/>
|
||||
<circle cx="256" cy="256" r="20" fill="#f4a22d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
69
contrib/darktable-api/web/src/App.jsx
Normal file
69
contrib/darktable-api/web/src/App.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import { ToastProvider } from './components/Toast.jsx';
|
||||
import { getSettings } from './api.js';
|
||||
import Settings from './screens/Settings.jsx';
|
||||
import Gallery from './screens/Gallery.jsx';
|
||||
|
||||
// ── App Context ───────────────────────────────────────────────────────────────
|
||||
|
||||
const AppContext = createContext(null);
|
||||
|
||||
export function useApp() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within AppContext');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
screen: 'gallery', // 'settings' | 'gallery'
|
||||
configured: false,
|
||||
};
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT':
|
||||
return { ...state, configured: action.configured, screen: action.configured ? 'gallery' : 'settings' };
|
||||
case 'GO_SETTINGS':
|
||||
return { ...state, screen: 'settings' };
|
||||
case 'GO_GALLERY':
|
||||
return { ...state, screen: 'gallery', configured: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── App Root ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
dispatch({ type: 'INIT', configured: !!settings });
|
||||
}, []);
|
||||
|
||||
const goSettings = useCallback(() => dispatch({ type: 'GO_SETTINGS' }), []);
|
||||
const goGallery = useCallback(() => dispatch({ type: 'GO_GALLERY' }), []);
|
||||
|
||||
const contextValue = { goSettings, goGallery, configured: state.configured };
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
<ToastProvider>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{state.screen === 'settings' ? (
|
||||
<Settings />
|
||||
) : (
|
||||
<Gallery />
|
||||
)}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
353
contrib/darktable-api/web/src/api.js
Normal file
353
contrib/darktable-api/web/src/api.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// Darktable REST API client
|
||||
// Handles OAuth2 client_credentials, token caching, all endpoints.
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
SETTINGS: 'dt_settings',
|
||||
TOKEN: 'dt_token',
|
||||
};
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
export function clearSettings() {
|
||||
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
}
|
||||
|
||||
// ── Token management ──────────────────────────────────────────────────────────
|
||||
|
||||
function getCachedToken() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.TOKEN);
|
||||
if (!raw) return null;
|
||||
const token = JSON.parse(raw);
|
||||
// Treat as expired 30 seconds early to avoid race conditions
|
||||
if (Date.now() >= token.expiresAt - 30_000) return null;
|
||||
return token.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheToken(accessToken, expiresIn) {
|
||||
const token = {
|
||||
accessToken,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
||||
}
|
||||
|
||||
let tokenFetchPromise = null;
|
||||
|
||||
async function fetchToken(settings) {
|
||||
const url = `${settings.baseUrl}/oauth/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: settings.clientId,
|
||||
client_secret: settings.clientSecret,
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, `Token request failed: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const expiresIn = data.expires_in ?? 3600;
|
||||
cacheToken(data.access_token, expiresIn);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
||||
|
||||
const cached = getCachedToken();
|
||||
if (cached) return cached;
|
||||
|
||||
// Deduplicate concurrent token fetches
|
||||
if (!tokenFetchPromise) {
|
||||
tokenFetchPromise = fetchToken(settings).finally(() => {
|
||||
tokenFetchPromise = null;
|
||||
});
|
||||
}
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
// ── Error class ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core fetch helper ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
||||
|
||||
const token = await getToken();
|
||||
const url = `${settings.baseUrl}${path}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
let message;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
message = json.error || json.message || text;
|
||||
} catch {
|
||||
message = text || res.statusText;
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiJson(path, options = {}) {
|
||||
const res = await apiFetch(path, {
|
||||
...options,
|
||||
headers: { Accept: 'application/json', ...options.headers },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Blob URL cache ────────────────────────────────────────────────────────────
|
||||
// Keeps a reference-counted map of blob URLs so we don't recreate them for the
|
||||
// same image+size combination within a session.
|
||||
|
||||
const blobCache = new Map(); // key → { url, refs }
|
||||
|
||||
export function releaseBlobUrl(key) {
|
||||
const entry = blobCache.get(key);
|
||||
if (!entry) return;
|
||||
entry.refs -= 1;
|
||||
if (entry.refs <= 0) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
blobCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPreviewBlob(id, size = 'thumb') {
|
||||
const key = `preview:${id}:${size}`;
|
||||
const existing = blobCache.get(key);
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
return { url: existing.url, key };
|
||||
}
|
||||
|
||||
// Use raw fetch so we can inspect the status before assuming it's an image.
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
||||
const token = await getToken();
|
||||
|
||||
const res = await fetch(
|
||||
`${settings.baseUrl}/api/v1/images/${id}/preview?size=${size}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
// 202 means the server has enqueued thumbnail generation; client should retry.
|
||||
if (res.status === 202) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '4', 10);
|
||||
return { generating: true, retryAfter };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobCache.set(key, { url, refs: 1 });
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
export async function getFullBlob(id) {
|
||||
const key = `full:${id}`;
|
||||
const existing = blobCache.get(key);
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
return { url: existing.url, key };
|
||||
}
|
||||
|
||||
const res = await apiFetch(`/api/v1/images/${id}/full`);
|
||||
// May return 202 with Retry-After; caller handles polling
|
||||
if (res.status === 202) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10);
|
||||
return { pending: true, retryAfter };
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobCache.set(key, { url, refs: 1 });
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listImages(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
const allowed = [
|
||||
'limit', 'offset', 'sort_by', 'sort_dir',
|
||||
'rating_min', 'rating_max', 'film_id',
|
||||
'from', 'to', 'search', 'tag',
|
||||
];
|
||||
for (const key of allowed) {
|
||||
if (params[key] !== undefined && params[key] !== '' && params[key] !== null) {
|
||||
qs.set(key, String(params[key]));
|
||||
}
|
||||
}
|
||||
const path = `/api/v1/images${qs.toString() ? '?' + qs.toString() : ''}`;
|
||||
return apiJson(path);
|
||||
}
|
||||
|
||||
export async function getImage(id) {
|
||||
return apiJson(`/api/v1/images/${id}`);
|
||||
}
|
||||
|
||||
export async function refreshImage(id) {
|
||||
return apiJson(`/api/v1/images/${id}/refresh`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function exportImage(id, { signal } = {}) {
|
||||
// 1. Enqueue the export (POST returns 202 immediately).
|
||||
await apiFetch(`/api/v1/images/${id}/export`, { method: 'POST', signal });
|
||||
|
||||
// 2. Poll GET /full until the JPEG is ready (up to 120 s).
|
||||
const settings = getSettings();
|
||||
const token = await getToken();
|
||||
const deadline = Date.now() + 120_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
||||
|
||||
const res = await fetch(`${settings.baseUrl}/api/v1/images/${id}/full`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.ok) return res.blob();
|
||||
if (res.status !== 202) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
// 202 → still generating, loop again
|
||||
}
|
||||
|
||||
throw new ApiError(504, 'Export timed out after 120 s');
|
||||
}
|
||||
|
||||
// ── Modules ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listModules(imageId) {
|
||||
return apiJson(`/api/v1/images/${imageId}/modules`);
|
||||
}
|
||||
|
||||
export async function getModule(imageId, op) {
|
||||
return apiJson(`/api/v1/images/${imageId}/modules/${op}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a module and optionally request a preview render.
|
||||
* Returns { preview: blobUrl } when previewSize is set and the server responds
|
||||
* with image/jpeg, or { data: json } for plain JSON responses.
|
||||
*
|
||||
* The PATCH with preview can take up to 30 seconds.
|
||||
*/
|
||||
export async function patchModule(imageId, op, body, previewSize = null) {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
let path = `/api/v1/images/${imageId}/modules/${op}`;
|
||||
if (previewSize) path += `?preview=${previewSize}`;
|
||||
|
||||
const url = `${settings.baseUrl}${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, image/jpeg',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// 35s timeout implemented via AbortController below
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('Content-Type') ?? '';
|
||||
if (contentType.includes('image/jpeg') || contentType.includes('image/')) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { previewUrl: url };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { data };
|
||||
}
|
||||
|
||||
// ── Tags & Films ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listTags() {
|
||||
return apiJson('/api/v1/tags');
|
||||
}
|
||||
|
||||
export async function listFilms() {
|
||||
return apiJson('/api/v1/films');
|
||||
}
|
||||
|
||||
// ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function checkHealth(baseUrl) {
|
||||
const url = `${baseUrl}/health`;
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
if (!res.ok) throw new ApiError(res.status, `Health check failed: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the full connection: health check + token fetch.
|
||||
* Returns { ok: true } or throws ApiError.
|
||||
*/
|
||||
export async function testConnection(settings) {
|
||||
await checkHealth(settings.baseUrl);
|
||||
await fetchToken(settings);
|
||||
return { ok: true };
|
||||
}
|
||||
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { listFilms, ApiError } from '../api.js';
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'datetime_taken:desc', label: 'Newest first' },
|
||||
{ value: 'datetime_taken:asc', label: 'Oldest first' },
|
||||
{ value: 'filename:asc', label: 'Filename A→Z' },
|
||||
{ value: 'filename:desc', label: 'Filename Z→A' },
|
||||
{ value: 'rating:desc', label: 'Top rated' },
|
||||
];
|
||||
|
||||
const STAR_OPTIONS = [
|
||||
{ value: '', label: 'Any rating' },
|
||||
{ value: '1', label: '★ and up' },
|
||||
{ value: '2', label: '★★ and up' },
|
||||
{ value: '3', label: '★★★ and up' },
|
||||
{ value: '4', label: '★★★★ and up' },
|
||||
{ value: '5', label: '★★★★★ only' },
|
||||
];
|
||||
|
||||
export default function FilterBar({ filters, onChange }) {
|
||||
const [films, setFilms] = useState([]);
|
||||
const [filmsError, setFilmsError] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const searchTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
listFilms()
|
||||
.then(data => setFilms(Array.isArray(data) ? data : (data.items ?? [])))
|
||||
.catch(err => {
|
||||
if (err instanceof ApiError && err.status === 0) return; // not configured yet
|
||||
setFilmsError('Could not load film rolls');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
// Debounce search by 400ms
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
onChange({ ...filters, search: value, offset: 0 });
|
||||
}, 400);
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleSortChange = useCallback((e) => {
|
||||
const [sort_by, sort_dir] = e.target.value.split(':');
|
||||
onChange({ ...filters, sort_by, sort_dir, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleRatingChange = useCallback((e) => {
|
||||
onChange({ ...filters, rating_min: e.target.value, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleFilmChange = useCallback((e) => {
|
||||
onChange({ ...filters, film_id: e.target.value, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const currentSort = `${filters.sort_by ?? 'datetime_taken'}:${filters.sort_dir ?? 'desc'}`;
|
||||
const activeFilterCount = [
|
||||
filters.search,
|
||||
filters.rating_min,
|
||||
filters.film_id,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
{/* Search row */}
|
||||
<div style={styles.searchRow}>
|
||||
<div style={styles.searchWrap}>
|
||||
<span style={styles.searchIcon}>⌕</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Search images…"
|
||||
defaultValue={filters.search ?? ''}
|
||||
onChange={handleSearchChange}
|
||||
style={{ paddingLeft: 36, height: 40 }}
|
||||
aria-label="Search images"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: '0 12px',
|
||||
fontSize: '0.8125rem',
|
||||
gap: 6,
|
||||
color: expanded || activeFilterCount > 0 ? 'var(--color-accent)' : undefined,
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => setExpanded(x => !x)}
|
||||
aria-expanded={expanded}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<span>⚙</span>
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span style={styles.badge}>{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded filters */}
|
||||
{expanded && (
|
||||
<div style={styles.filterRow}>
|
||||
<div style={{ flex: 1, minWidth: 140 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={currentSort}
|
||||
onChange={handleSortChange}
|
||||
aria-label="Sort order"
|
||||
>
|
||||
{SORT_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Min rating
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={filters.rating_min ?? ''}
|
||||
onChange={handleRatingChange}
|
||||
aria-label="Minimum star rating"
|
||||
>
|
||||
{STAR_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{films.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 150 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Film roll
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={filters.film_id ?? ''}
|
||||
onChange={handleFilmChange}
|
||||
aria-label="Film roll"
|
||||
>
|
||||
<option value="">All rolls</option>
|
||||
{films.map(f => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.folder ?? f.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 0 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ height: 38, padding: '0 12px', fontSize: '0.8125rem', color: 'var(--color-danger)' }}
|
||||
onClick={() => onChange({ ...filters, search: '', rating_min: '', film_id: '', offset: 0 })}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
position: 'sticky',
|
||||
top: 'var(--header-height)',
|
||||
zIndex: 'var(--z-filter)',
|
||||
},
|
||||
searchRow: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchWrap: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
searchIcon: {
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '1.1rem',
|
||||
pointerEvents: 'none',
|
||||
lineHeight: 1,
|
||||
},
|
||||
filterRow: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 4,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 4,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: '9999px',
|
||||
background: 'var(--color-accent)',
|
||||
color: '#0d0d0d',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 3px',
|
||||
},
|
||||
};
|
||||
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getPreviewBlob, releaseBlobUrl } from '../api.js';
|
||||
|
||||
const COLOR_LABEL_COLORS = {
|
||||
red: '#e05252',
|
||||
yellow: '#f4c842',
|
||||
green: '#52c06e',
|
||||
blue: '#5299e0',
|
||||
purple: '#a052c0',
|
||||
};
|
||||
|
||||
function StarRating({ rating }) {
|
||||
if (!rating || rating < 1) return null;
|
||||
return (
|
||||
<div style={{
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||
}}>
|
||||
{'★'.repeat(Math.min(5, rating))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorDots({ labels }) {
|
||||
if (!labels || labels.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{labels.map(label => (
|
||||
<span
|
||||
key={label}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: COLOR_LABEL_COLORS[label] ?? '#aaa',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
title={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageCard({ image, onClick }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const blobKeyRef = useRef(null);
|
||||
const isMounted = useRef(true);
|
||||
const retriesRef = useRef(0);
|
||||
const retryTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
retriesRef.current = 0;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setGenerating(false);
|
||||
|
||||
function loadPreview() {
|
||||
getPreviewBlob(image.id, 'thumb')
|
||||
.then((result) => {
|
||||
if (!isMounted.current) {
|
||||
if (result.key) releaseBlobUrl(result.key);
|
||||
return;
|
||||
}
|
||||
if (result.generating) {
|
||||
setLoading(false);
|
||||
setGenerating(true);
|
||||
if (retriesRef.current < 15) {
|
||||
retriesRef.current += 1;
|
||||
retryTimerRef.current = setTimeout(loadPreview, (result.retryAfter ?? 4) * 1000);
|
||||
} else {
|
||||
setGenerating(false);
|
||||
setError(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = result.key;
|
||||
setBlobUrl(result.url);
|
||||
setLoading(false);
|
||||
setGenerating(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted.current) {
|
||||
setLoading(false);
|
||||
setGenerating(false);
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadPreview();
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (blobKeyRef.current) {
|
||||
releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [image.id]);
|
||||
|
||||
// Compute aspect ratio for the thumbnail placeholder
|
||||
const aspect = image.width && image.height
|
||||
? Math.min(1.5, Math.max(0.6, image.height / image.width))
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'block',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'transform var(--transition-fast), box-shadow var(--transition-fast)',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
aria-label={`Open ${image.filename}`}
|
||||
type="button"
|
||||
>
|
||||
{/* Thumbnail container — padding-bottom trick for aspect ratio */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: `${aspect * 100}%`,
|
||||
background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
{(loading || generating) && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}>
|
||||
<span className="spinner" />
|
||||
{generating && (
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
||||
Generating…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && !generating && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '1.5rem', opacity: 0.4 }}>⊘</span>
|
||||
</div>
|
||||
)}
|
||||
{blobUrl && !error && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={image.filename}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom overlay: stars + color dots */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<StarRating rating={image.rating} />
|
||||
<ColorDots labels={image.color_labels} />
|
||||
</div>
|
||||
|
||||
{/* RAW badge */}
|
||||
{image.is_raw && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
RAW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
{image.filename}
|
||||
</span>
|
||||
{image.datetime_taken && (
|
||||
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
{formatDate(image.datetime_taken)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
try {
|
||||
return new Date(dt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dt;
|
||||
}
|
||||
}
|
||||
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { listModules, ApiError } from '../api.js';
|
||||
import ModulePanel from './ModulePanel.jsx';
|
||||
|
||||
export default function ModuleList({ imageId, onPreviewUpdate }) {
|
||||
const [modules, setModules] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setModules(null);
|
||||
|
||||
listModules(imageId)
|
||||
.then(data => {
|
||||
// API may return an array or { items: [] }
|
||||
const list = Array.isArray(data) ? data : (data.items ?? data.modules ?? []);
|
||||
setModules(list);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [imageId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32 }}>
|
||||
<span className="spinner spinner-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-banner" style={{ margin: '0 0 8px' }}>
|
||||
<span>⚠</span>
|
||||
<span>Modules: {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modules || modules.length === 0) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: '24px' }}>
|
||||
<span className="empty-state-icon">◻</span>
|
||||
<p className="text-sm text-muted">No development modules found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<h3 style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||||
Development modules ({modules.length})
|
||||
</h3>
|
||||
{modules.map((mod, idx) => (
|
||||
<ModulePanel
|
||||
key={`${mod.operation}-${mod.history_num ?? idx}`}
|
||||
imageId={imageId}
|
||||
module={mod}
|
||||
onPreviewUpdate={onPreviewUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { patchModule, ApiError } from '../api.js';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
// ── Known param schemas ───────────────────────────────────────────────────────
|
||||
// Provides type and range hints for well-known module parameters.
|
||||
// Fallback: treat everything as a generic number / string.
|
||||
|
||||
const MODULE_PARAMS = {
|
||||
exposure: {
|
||||
exposure: { type: 'float', min: -18, max: 18, step: 0.01, label: 'Exposure (EV)' },
|
||||
black: { type: 'float', min: -1, max: 1, step: 0.001, label: 'Black point' },
|
||||
mode: { type: 'int', label: 'Mode' },
|
||||
},
|
||||
temperature: {
|
||||
red: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Red' },
|
||||
green: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green' },
|
||||
blue: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Blue' },
|
||||
g2: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green2' },
|
||||
},
|
||||
filmicrgb: {
|
||||
grey_point_source: { type: 'float', min: 0.1, max: 36, step: 0.01, label: 'Grey point src (%)' },
|
||||
black_point_source: { type: 'float', min: -16, max: -0.1, step: 0.01, label: 'Black point src (EV)' },
|
||||
white_point_source: { type: 'float', min: 0.1, max: 16, step: 0.01, label: 'White point src (EV)' },
|
||||
contrast: { type: 'float', min: 0, max: 5, step: 0.01, label: 'Contrast' },
|
||||
saturation: { type: 'float', min: 0, max: 4, step: 0.01, label: 'Saturation' },
|
||||
balance: { type: 'float', min: -50, max: 50, step: 0.1, label: 'Shadows↔Highlights' },
|
||||
output_power: { type: 'float', min: 0, max: 10, step: 0.01, label: 'Output power' },
|
||||
latitude: { type: 'float', min: 0.01, max: 99, step: 0.1, label: 'Latitude (%)' },
|
||||
noise_level: { type: 'float', min: 0, max: 0.5, step: 0.001, label: 'Noise level' },
|
||||
},
|
||||
highlights: {
|
||||
mode: { type: 'int', label: 'Mode' },
|
||||
strength: { type: 'float', min: 0, max: 1, step: 0.01, label: 'Strength' },
|
||||
clip: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Clip' },
|
||||
noise_level: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Noise level' },
|
||||
iterations: { type: 'int', label: 'Iterations' },
|
||||
},
|
||||
channelmixerrgb: {
|
||||
temperature: { type: 'float', min: 1667, max: 25000, step: 10, label: 'Temperature (K)' },
|
||||
illuminant: { type: 'int', label: 'Illuminant' },
|
||||
adaptation: { type: 'int', label: 'Adaptation' },
|
||||
},
|
||||
flip: {
|
||||
orientation: { type: 'int', label: 'Orientation' },
|
||||
},
|
||||
gamma: {
|
||||
gamma: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Gamma' },
|
||||
linear: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Linear' },
|
||||
},
|
||||
rawprepare: {
|
||||
left: { type: 'int', label: 'Left crop (px)' },
|
||||
top: { type: 'int', label: 'Top crop (px)' },
|
||||
right: { type: 'int', label: 'Right crop (px)' },
|
||||
bottom: { type: 'int', label: 'Bottom crop (px)' },
|
||||
raw_white_point: { type: 'int', label: 'Raw white point' },
|
||||
},
|
||||
demosaic: {
|
||||
demosaicing_method: { type: 'int', label: 'Method' },
|
||||
green_eq: { type: 'int', label: 'Green equalization' },
|
||||
median_thrs: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Median threshold' },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function inferType(key, value, opName) {
|
||||
const schema = MODULE_PARAMS[opName]?.[key];
|
||||
if (schema) return schema;
|
||||
|
||||
const numVal = Number(value);
|
||||
if (typeof value === 'boolean') return { type: 'bool' };
|
||||
if (!isNaN(numVal)) {
|
||||
const isFloat = String(value).includes('.') || (Number.isFinite(numVal) && !Number.isInteger(numVal));
|
||||
return isFloat
|
||||
? { type: 'float', min: -100, max: 100, step: 0.01, label: key }
|
||||
: { type: 'int', label: key };
|
||||
}
|
||||
return { type: 'string', label: key };
|
||||
}
|
||||
|
||||
// ── ParamField ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ParamField({ paramKey, value, schema, onChange }) {
|
||||
const label = schema?.label ?? paramKey;
|
||||
|
||||
if (schema?.type === 'bool' || typeof value === 'boolean') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minHeight: 36 }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={e => onChange(paramKey, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema?.type === 'float' && schema.min !== undefined && schema.max !== undefined) {
|
||||
const numVal = Number(value);
|
||||
const displayVal = Number.isNaN(numVal) ? 0 : numVal;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
||||
<span style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text)' }}>
|
||||
{displayVal.toFixed(schema.step < 0.01 ? 4 : schema.step < 0.1 ? 3 : 2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="param-slider"
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
step={schema.step ?? 0.01}
|
||||
value={displayVal}
|
||||
onChange={e => onChange(paramKey, parseFloat(e.target.value))}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.min}</span>
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// int / string — text input
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 36 }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', flex: 1 }}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 90, height: 32, padding: '0 8px', fontSize: '0.8125rem' }}
|
||||
value={value ?? ''}
|
||||
step={schema?.type === 'float' ? (schema.step ?? 0.01) : 1}
|
||||
onChange={e => {
|
||||
const raw = e.target.value;
|
||||
const num = Number(raw);
|
||||
onChange(paramKey, Number.isNaN(num) ? raw : num);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ModulePanel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function paramsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return { ...action.params };
|
||||
case 'SET':
|
||||
return { ...state, [action.key]: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModulePanel({ imageId, module: mod, onPreviewUpdate }) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(mod.enabled ?? true);
|
||||
const [params, dispatchParams] = useReducer(paramsReducer, mod.params ?? {});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [renderState, setRenderState] = useState(null); // null | 'rendering' | 'done' | 'error'
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const previewUrlRef = useRef(null);
|
||||
|
||||
// Sync when module prop changes (e.g., after gallery refresh)
|
||||
useEffect(() => {
|
||||
setEnabled(mod.enabled ?? true);
|
||||
dispatchParams({ type: 'RESET', params: mod.params ?? {} });
|
||||
setDirty(false);
|
||||
}, [mod]);
|
||||
|
||||
// Cleanup preview blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleParamChange(key, value) {
|
||||
dispatchParams({ type: 'SET', key, value });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function handleEnabledChange(newEnabled) {
|
||||
setEnabled(newEnabled);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
setApplying(true);
|
||||
setRenderState('rendering');
|
||||
|
||||
const body = {
|
||||
enabled,
|
||||
params,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35_000);
|
||||
|
||||
try {
|
||||
const result = await patchModule(imageId, mod.operation, body, 'medium');
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (result.previewUrl) {
|
||||
// Revoke previous preview blob
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = result.previewUrl;
|
||||
setPreviewUrl(result.previewUrl);
|
||||
onPreviewUpdate?.(result.previewUrl);
|
||||
setRenderState('done');
|
||||
toast(`${mod.operation}: applied`, 'success');
|
||||
} else {
|
||||
setRenderState('done');
|
||||
toast(`${mod.operation}: applied (no preview)`, 'success');
|
||||
}
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
setRenderState('error');
|
||||
toast(`${mod.operation}: render timed out`, 'error');
|
||||
} else {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setRenderState('error');
|
||||
toast(`${mod.operation}: ${msg}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const paramEntries = Object.entries(params);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
{/* Header row — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
minHeight: 44,
|
||||
}}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{/* Enabled toggle — stop propagation so clicking it doesn't toggle open */}
|
||||
<span
|
||||
role="none"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(!enabled);
|
||||
}}
|
||||
style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}
|
||||
title={enabled ? 'Disable module' : 'Enable module'}
|
||||
>
|
||||
<label className="toggle" style={{ pointerEvents: 'none' }}>
|
||||
<input type="checkbox" readOnly checked={enabled} tabIndex={-1} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<span style={{ flex: 1, fontSize: '0.9rem', fontWeight: 500, color: enabled ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
|
||||
{mod.multi_name ? `${mod.operation} · ${mod.multi_name}` : mod.operation}
|
||||
</span>
|
||||
|
||||
{dirty && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'var(--color-accent)',
|
||||
background: 'var(--color-accent-dim)',
|
||||
padding: '1px 7px',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
modified
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-faint)',
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
transition: 'transform var(--transition-fast)',
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded body */}
|
||||
{open && (
|
||||
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
|
||||
{/* Module info row */}
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
{mod.history_num !== undefined && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
||||
history #{mod.history_num}
|
||||
</span>
|
||||
)}
|
||||
{mod.module_version !== undefined && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
||||
v{mod.module_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Param fields */}
|
||||
{paramEntries.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{paramEntries.map(([key, value]) => {
|
||||
const schema = inferType(key, value, mod.operation);
|
||||
return (
|
||||
<ParamField
|
||||
key={key}
|
||||
paramKey={key}
|
||||
value={value}
|
||||
schema={schema}
|
||||
onChange={handleParamChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-faint)' }}>
|
||||
No editable parameters
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Apply button + render state */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleApply}
|
||||
disabled={applying || (!dirty && renderState !== null)}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Rendering…
|
||||
</>
|
||||
) : 'Apply'}
|
||||
</button>
|
||||
|
||||
{renderState === 'rendering' && (
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center' }}>
|
||||
Waiting for preview render (up to 30 s)…
|
||||
</p>
|
||||
)}
|
||||
{renderState === 'error' && (
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-danger)', textAlign: 'center' }}>
|
||||
Render failed or timed out.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline preview after apply */}
|
||||
{previewUrl && renderState === 'done' && (
|
||||
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Module preview"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-faint)', textAlign: 'center', padding: '4px 0' }}>
|
||||
Preview after {mod.operation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'ADD':
|
||||
return [...state, action.toast];
|
||||
case 'REMOVE':
|
||||
return state.filter(t => t.id !== action.id);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, dispatch] = useReducer(reducer, []);
|
||||
const timers = useRef({});
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
dispatch({ type: 'REMOVE', id });
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message, type = 'info', duration = 4000) => {
|
||||
const id = ++nextId;
|
||||
dispatch({ type: 'ADD', toast: { id, message, type } });
|
||||
if (duration > 0) {
|
||||
timers.current[id] = setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
return id;
|
||||
}, [dismiss]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} dismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICONS = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
};
|
||||
|
||||
function ToastContainer({ toasts, dismiss }) {
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 'max(80px, calc(env(safe-area-inset-bottom, 0px) + 80px))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 'var(--z-toast)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
width: 'min(calc(100vw - 32px), 400px)',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{toasts.map(t => (
|
||||
<ToastItem key={t.id} toast={t} dismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, dismiss }) {
|
||||
const colorMap = {
|
||||
info: 'var(--color-text)',
|
||||
success: 'var(--color-success)',
|
||||
warning: 'var(--color-warning)',
|
||||
error: 'var(--color-danger)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
fontSize: '0.9rem',
|
||||
pointerEvents: 'all',
|
||||
animation: 'toastIn 200ms ease',
|
||||
maxWidth: '100%',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => dismiss(toast.id)}
|
||||
>
|
||||
<span style={{ color: colorMap[toast.type] ?? colorMap.info, fontSize: '1rem', flexShrink: 0, lineHeight: 1.5 }}>
|
||||
{ICONS[toast.type] ?? ICONS.info}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--color-text)', lineHeight: 1.5, wordBreak: 'break-word' }}>
|
||||
{toast.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keyframe injected once
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
456
contrib/darktable-api/web/src/index.css
Normal file
456
contrib/darktable-api/web/src/index.css
Normal file
@@ -0,0 +1,456 @@
|
||||
/* ── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||
:root {
|
||||
--color-bg: #0d0d0d;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-surface-2: #242424;
|
||||
--color-surface-3: #2e2e2e;
|
||||
--color-border: #333333;
|
||||
--color-accent: #f4a22d;
|
||||
--color-accent-dim: rgba(244, 162, 45, 0.15);
|
||||
--color-accent-hover: #f8b84a;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-muted: #888888;
|
||||
--color-text-faint: #555555;
|
||||
--color-danger: #e05252;
|
||||
--color-success: #52c06e;
|
||||
--color-warning: #f4a22d;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
|
||||
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
--tap-target: 44px;
|
||||
--header-height: 56px;
|
||||
--filter-height: 56px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-med: 250ms ease;
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--z-header: 100;
|
||||
--z-filter: 90;
|
||||
--z-backdrop: 200;
|
||||
--z-sheet: 210;
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
/* ── Reset ───────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||
h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
|
||||
h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; }
|
||||
h3 { font-size: 1rem; font-weight: 600; }
|
||||
h4 { font-size: 0.875rem; font-weight: 600; }
|
||||
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
.text-accent { color: var(--color-accent); }
|
||||
.text-mono { font-family: var(--font-mono); }
|
||||
|
||||
/* ── Utility classes ────────────────────────────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-1 { gap: 4px; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.flex-1 { flex: 1; }
|
||||
|
||||
.w-full { width: 100%; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
|
||||
/* ── Shared component styles ─────────────────────────────────────────────── */
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: var(--tap-target);
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: #0d0d0d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0;
|
||||
width: var(--tap-target);
|
||||
height: var(--tap-target);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
height: var(--tap-target);
|
||||
padding: 0 12px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9375rem;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
height: var(--tap-target);
|
||||
padding: 0 32px 0 12px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9375rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tag pill */
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
background-color: var(--color-surface-3);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Star display */
|
||||
.stars {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(224, 82, 82, 0.12);
|
||||
border: 1px solid rgba(224, 82, 82, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: #f08080;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-surface-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.param-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface-3);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #0d0d0d;
|
||||
box-shadow: 0 0 0 2px rgba(244, 162, 45, 0.3);
|
||||
}
|
||||
|
||||
.param-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #0d0d0d;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Safe area insets for notched phones */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
11
contrib/darktable-api/web/src/main.jsx
Normal file
11
contrib/darktable-api/web/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { listImages, ApiError } from '../api.js';
|
||||
import { useApp } from '../App.jsx';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
import FilterBar from '../components/FilterBar.jsx';
|
||||
import ImageCard from '../components/ImageCard.jsx';
|
||||
import ImageDetail from './ImageDetail.jsx';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ── State machine for the image list ─────────────────────────────────────────
|
||||
|
||||
const initialListState = {
|
||||
items: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: null,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
};
|
||||
|
||||
function listReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'LOAD_START':
|
||||
return { ...state, loading: true, error: null };
|
||||
case 'LOAD_MORE_START':
|
||||
return { ...state, loadingMore: true, error: null };
|
||||
case 'LOAD_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
items: action.replace ? action.items : [...state.items, ...action.items],
|
||||
total: action.total,
|
||||
offset: action.offset + action.items.length,
|
||||
hasMore: action.offset + action.items.length < action.total,
|
||||
error: null,
|
||||
};
|
||||
case 'LOAD_ERROR':
|
||||
return { ...state, loading: false, loadingMore: false, error: action.error };
|
||||
case 'RESET':
|
||||
return { ...initialListState };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFilters = {
|
||||
sort_by: 'datetime_taken',
|
||||
sort_dir: 'desc',
|
||||
search: '',
|
||||
rating_min: '',
|
||||
film_id: '',
|
||||
};
|
||||
|
||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
const PTR_THRESHOLD = 70; // px to trigger refresh
|
||||
|
||||
// ── Gallery ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Gallery() {
|
||||
const { goSettings } = useApp();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [listState, dispatch] = useReducer(listReducer, initialListState);
|
||||
const [filters, setFilters] = useState(defaultFilters);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
|
||||
// Pull-to-refresh state
|
||||
const [ptrY, setPtrY] = useState(0);
|
||||
const [ptrActive, setPtrActive] = useState(false);
|
||||
const touchStartY = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
// Sentinel element for IntersectionObserver (infinite scroll)
|
||||
const sentinelRef = useRef(null);
|
||||
const observerRef = useRef(null);
|
||||
|
||||
// Current active fetch abort controller
|
||||
const fetchControllerRef = useRef(null);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const loadPage = useCallback(async (currentOffset, replace) => {
|
||||
// Cancel any previous fetch
|
||||
if (fetchControllerRef.current) fetchControllerRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
fetchControllerRef.current = controller;
|
||||
|
||||
dispatch({ type: replace ? 'LOAD_START' : 'LOAD_MORE_START' });
|
||||
|
||||
try {
|
||||
const params = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: currentOffset,
|
||||
sort_by: filters.sort_by,
|
||||
sort_dir: filters.sort_dir,
|
||||
};
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.rating_min) params.rating_min = filters.rating_min;
|
||||
if (filters.film_id) params.film_id = filters.film_id;
|
||||
|
||||
const data = await listImages(params);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const total = data.total ?? items.length;
|
||||
|
||||
dispatch({
|
||||
type: 'LOAD_SUCCESS',
|
||||
items,
|
||||
total,
|
||||
offset: currentOffset,
|
||||
replace,
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
dispatch({ type: 'LOAD_ERROR', error: msg });
|
||||
toast(msg, 'error');
|
||||
}
|
||||
}, [filters, toast]);
|
||||
|
||||
// Initial load and when filters change
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'RESET' });
|
||||
loadPage(0, true);
|
||||
// Scroll to top
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [filters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Note: we intentionally omit loadPage from deps to avoid double-fires;
|
||||
// filters change triggers reset + load, which is correct.
|
||||
|
||||
// ── IntersectionObserver for infinite scroll ────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current?.disconnect();
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
!listState.loading &&
|
||||
!listState.loadingMore &&
|
||||
listState.hasMore
|
||||
) {
|
||||
loadPage(listState.offset, false);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observerRef.current.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, [listState.loading, listState.loadingMore, listState.hasMore, listState.offset, loadPage]);
|
||||
|
||||
// ── Pull-to-refresh touch handlers ─────────────────────────────────────────
|
||||
|
||||
function handleTouchStart(e) {
|
||||
if (scrollRef.current?.scrollTop === 0) {
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e) {
|
||||
if (touchStartY.current === null) return;
|
||||
const dy = e.touches[0].clientY - touchStartY.current;
|
||||
if (dy > 0) {
|
||||
setPtrY(Math.min(dy * 0.5, PTR_THRESHOLD + 20));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (ptrY >= PTR_THRESHOLD) {
|
||||
setPtrActive(true);
|
||||
dispatch({ type: 'RESET' });
|
||||
loadPage(0, true).finally(() => {
|
||||
setPtrActive(false);
|
||||
setPtrY(0);
|
||||
});
|
||||
} else {
|
||||
setPtrY(0);
|
||||
}
|
||||
touchStartY.current = null;
|
||||
}
|
||||
|
||||
// ── Filter handler ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleFiltersChange = useCallback((newFilters) => {
|
||||
setFilters(newFilters);
|
||||
}, []);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const showEmpty = !listState.loading && !listState.error && listState.items.length === 0;
|
||||
const showError = !!listState.error && listState.items.length === 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Fixed header */}
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
height: 'var(--header-height)',
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 'var(--z-header)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src="/icon.svg" alt="" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-accent)' }}>
|
||||
darktable
|
||||
</span>
|
||||
{listState.total > 0 && (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
{listState.total.toLocaleString()} images
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-icon"
|
||||
onClick={goSettings}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
style={{ fontSize: '1.25rem' }}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar filters={filters} onChange={handleFiltersChange} />
|
||||
|
||||
{/* Scrollable gallery area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain', position: 'relative' }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Pull-to-refresh indicator */}
|
||||
{ptrY > 10 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: Math.min(ptrY, PTR_THRESHOLD + 20),
|
||||
color: ptrY >= PTR_THRESHOLD ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
gap: 8,
|
||||
transition: ptrActive ? 'height var(--transition-med)' : 'none',
|
||||
}}>
|
||||
{ptrActive
|
||||
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Refreshing…</>
|
||||
: (ptrY >= PTR_THRESHOLD ? '↑ Release to refresh' : '↓ Pull to refresh')
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial loading */}
|
||||
{listState.loading && listState.items.length === 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 64, gap: 12 }}>
|
||||
<span className="spinner spinner-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error (no items) */}
|
||||
{showError && (
|
||||
<div style={{ padding: '24px 16px' }}>
|
||||
<div className="error-banner">
|
||||
<span>⚠</span>
|
||||
<div>
|
||||
<p>{listState.error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, height: 36, fontSize: '0.875rem' }}
|
||||
onClick={() => loadPage(0, true)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div className="empty-state">
|
||||
<span className="empty-state-icon">◻</span>
|
||||
<p>No images found</p>
|
||||
<p className="text-sm text-muted">Try adjusting your filters</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image grid */}
|
||||
{listState.items.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 6,
|
||||
padding: 6,
|
||||
}}>
|
||||
{listState.items.map(img => (
|
||||
<ImageCard
|
||||
key={img.id}
|
||||
image={img}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
style={{ height: 1 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Load-more spinner */}
|
||||
{listState.loadingMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px 0' }}>
|
||||
<span className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End-of-list indicator */}
|
||||
{!listState.hasMore && listState.items.length > 0 && (
|
||||
<p style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-faint)',
|
||||
}}>
|
||||
All {listState.total.toLocaleString()} images loaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image detail bottom sheet */}
|
||||
{selectedImage && (
|
||||
<ImageDetail
|
||||
image={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getPreviewBlob, releaseBlobUrl, refreshImage, exportImage, ApiError } from '../api.js';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
import ModuleList from '../components/ModuleList.jsx';
|
||||
|
||||
const PREVIEW_SIZES = ['thumb', 'small', 'medium', 'large', 'xlarge'];
|
||||
|
||||
// ── EXIF metadata rows ────────────────────────────────────────────────────────
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120, flexShrink: 0 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', wordBreak: 'break-word' }}>
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview area ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PreviewImage({ imageId, size, onModulePreview, modulePreviewUrl }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const blobKeyRef = useRef(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
getPreviewBlob(imageId, size)
|
||||
.then(({ url, key }) => {
|
||||
if (!isMounted.current) { releaseBlobUrl(key); return; }
|
||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = key;
|
||||
setBlobUrl(url);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted.current) { setLoading(false); setError(true); }
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (blobKeyRef.current) { releaseBlobUrl(blobKeyRef.current); blobKeyRef.current = null; }
|
||||
};
|
||||
}, [imageId, size]);
|
||||
|
||||
const displayUrl = modulePreviewUrl ?? blobUrl;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
background: '#111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{loading && !displayUrl && (
|
||||
<span className="spinner spinner-lg" />
|
||||
)}
|
||||
{error && !displayUrl && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>⊘</div>
|
||||
<p className="text-sm">Preview unavailable</p>
|
||||
</div>
|
||||
)}
|
||||
{displayUrl && (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="Image preview"
|
||||
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{modulePreviewUrl && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8, right: 8,
|
||||
background: 'rgba(244, 162, 45, 0.9)',
|
||||
color: '#000',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
}}>
|
||||
MODULE PREVIEW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ImageDetail({ image, onClose }) {
|
||||
const { toast } = useToast();
|
||||
const [previewSizeIdx, setPreviewSizeIdx] = useState(2); // 'medium'
|
||||
const [tab, setTab] = useState('info'); // 'info' | 'modules'
|
||||
const [actionBusy, setActionBusy] = useState(null); // 'refresh' | 'export' | null
|
||||
const [modulePreviewUrl, setModulePreviewUrl] = useState(null);
|
||||
const modulePreviewUrlRef = useRef(null);
|
||||
|
||||
// Animate in
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, []);
|
||||
|
||||
// Cleanup module preview blob
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function cyclePreviewSize() {
|
||||
setPreviewSizeIdx(i => (i + 1) % PREVIEW_SIZES.length);
|
||||
setModulePreviewUrl(null); // clear module preview when switching size
|
||||
}
|
||||
|
||||
function handleModulePreview(url) {
|
||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
||||
modulePreviewUrlRef.current = url;
|
||||
setModulePreviewUrl(url);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setActionBusy('refresh');
|
||||
try {
|
||||
await refreshImage(image.id);
|
||||
toast('Thumbnails regenerated', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof ApiError ? err.message : String(err), 'error');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const exportAbortRef = useRef(null);
|
||||
|
||||
async function handleExport() {
|
||||
// Cancel any in-progress export if the button is clicked again.
|
||||
exportAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
exportAbortRef.current = controller;
|
||||
|
||||
setActionBusy('export');
|
||||
try {
|
||||
const blob = await exportImage(image.id, { signal: controller.signal });
|
||||
const stem = image.filename?.replace(/\.[^.]+$/, '') ?? 'export';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stem}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast('Export downloaded', 'success');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.message === 'Cancelled') return;
|
||||
toast(e instanceof ApiError ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
const currentSize = PREVIEW_SIZES[previewSizeIdx];
|
||||
|
||||
const meta = image.metadata ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
zIndex: 'var(--z-backdrop)',
|
||||
transition: 'opacity var(--transition-slow)',
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={image.filename}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 'var(--z-sheet)',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
maxHeight: '92dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transform: visible ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform var(--transition-slow)',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
|
||||
<div style={{
|
||||
width: 40, height: 4,
|
||||
borderRadius: 2,
|
||||
background: 'var(--color-border)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Sheet header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 16px 12px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
gap: 12,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{
|
||||
fontSize: '1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{image.filename}
|
||||
</h2>
|
||||
{image.path && (
|
||||
<p style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 2,
|
||||
}}>
|
||||
{image.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-icon"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain' }}>
|
||||
{/* Preview image — tap to cycle sizes */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cyclePreviewSize}
|
||||
title={`Preview size: ${currentSize} (tap to cycle)`}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<PreviewImage
|
||||
imageId={image.id}
|
||||
size={currentSize}
|
||||
modulePreviewUrl={modulePreviewUrl}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.6875rem',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{currentSize} · tap to cycle
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: 10, padding: '12px 16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleRefresh}
|
||||
disabled={actionBusy !== null}
|
||||
>
|
||||
{actionBusy === 'refresh'
|
||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Refreshing…</>
|
||||
: '↺ Refresh'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleExport}
|
||||
disabled={actionBusy !== null}
|
||||
>
|
||||
{actionBusy === 'export'
|
||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Exporting…</>
|
||||
: '⬇ Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
padding: '0 16px',
|
||||
gap: 4,
|
||||
}}>
|
||||
{['info', 'modules'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${tab === t ? 'var(--color-accent)' : 'transparent'}`,
|
||||
padding: '10px 16px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: tab === t ? 600 : 400,
|
||||
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color var(--transition-fast), border-color var(--transition-fast)',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
{t === 'info' ? 'Info' : 'Modules'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ padding: '16px' }}>
|
||||
{tab === 'info' && (
|
||||
<InfoTab image={image} meta={meta} />
|
||||
)}
|
||||
{tab === 'modules' && (
|
||||
<ModuleList
|
||||
imageId={image.id}
|
||||
onPreviewUpdate={handleModulePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Safe area bottom padding */}
|
||||
<div style={{ height: 'max(16px, env(safe-area-inset-bottom, 16px))' }} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Info tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoTab({ image, meta }) {
|
||||
const stars = image.rating > 0 ? '★'.repeat(image.rating) : '—';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Tags */}
|
||||
{image.tags && image.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, paddingBottom: 12 }}>
|
||||
{image.tags.map(tag => (
|
||||
<span key={tag} className="tag-pill">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0 8px' }} />
|
||||
|
||||
<MetaRow label="Rating" value={stars} />
|
||||
<MetaRow label="Date taken" value={image.datetime_taken} />
|
||||
<MetaRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height}` : null} />
|
||||
<MetaRow label="Camera" value={[image.maker, image.model].filter(Boolean).join(' ')} />
|
||||
<MetaRow label="Type" value={image.is_raw ? 'RAW' : 'JPEG/other'} />
|
||||
<MetaRow label="Film roll" value={image.film_name ?? image.film_id} />
|
||||
|
||||
{/* Color labels */}
|
||||
{image.color_labels && image.color_labels.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120 }}>
|
||||
Color labels
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{image.color_labels.map(l => (
|
||||
<span key={l} className="tag-pill" style={{ textTransform: 'capitalize' }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra metadata fields from image.metadata object */}
|
||||
{Object.keys(meta).length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '8px 0 4px' }} />
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)', marginBottom: 4 }}>
|
||||
Additional metadata
|
||||
</p>
|
||||
{Object.entries(meta).map(([key, value]) => (
|
||||
<MetaRow key={key} label={key} value={value} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { useApp } from '../App.jsx';
|
||||
import { getSettings, saveSettings, testConnection, ApiError } from '../api.js';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
|
||||
const initialForm = {
|
||||
baseUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
};
|
||||
|
||||
function formReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'SET_FIELD':
|
||||
return { ...state, [action.field]: action.value };
|
||||
case 'LOAD':
|
||||
return { ...action.data };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const statusIdle = { kind: 'idle' };
|
||||
const statusTesting = { kind: 'testing' };
|
||||
const statusOk = { kind: 'ok' };
|
||||
const statusErr = (msg) => ({ kind: 'error', msg });
|
||||
|
||||
export default function Settings() {
|
||||
const { goGallery } = useApp();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [form, dispatchForm] = useReducer(formReducer, initialForm);
|
||||
const [status, setStatus] = React.useState(statusIdle);
|
||||
|
||||
useEffect(() => {
|
||||
const s = getSettings();
|
||||
if (s) {
|
||||
dispatchForm({
|
||||
type: 'LOAD',
|
||||
data: {
|
||||
baseUrl: s.baseUrl ?? '',
|
||||
clientId: s.clientId ?? '',
|
||||
clientSecret: s.clientSecret ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function setField(field) {
|
||||
return (e) => dispatchForm({ type: 'SET_FIELD', field, value: e.target.value });
|
||||
}
|
||||
|
||||
async function handleTest(e) {
|
||||
e.preventDefault();
|
||||
setStatus(statusTesting);
|
||||
|
||||
const settings = {
|
||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
await testConnection(settings);
|
||||
setStatus(statusOk);
|
||||
toast('Connection successful', 'success');
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setStatus(statusErr(msg));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setStatus(statusTesting);
|
||||
|
||||
const settings = {
|
||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
await testConnection(settings);
|
||||
saveSettings(settings);
|
||||
setStatus(statusOk);
|
||||
toast('Settings saved', 'success');
|
||||
goGallery();
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setStatus(statusErr(msg));
|
||||
}
|
||||
}
|
||||
|
||||
const isBusy = status.kind === 'testing';
|
||||
const isValid = form.baseUrl.trim() && form.clientId.trim() && form.clientSecret.trim();
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
{/* Header */}
|
||||
<header style={styles.header}>
|
||||
<div style={styles.logoRow}>
|
||||
<img src="/icon.svg" alt="" style={styles.logo} />
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.25rem' }}>darktable Gallery</h1>
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
API connection settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Form */}
|
||||
<main style={styles.main}>
|
||||
<form onSubmit={handleSave} style={styles.form} noValidate>
|
||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="baseUrl">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
id="baseUrl"
|
||||
className="form-input"
|
||||
type="url"
|
||||
placeholder="http://localhost:8080"
|
||||
value={form.baseUrl}
|
||||
onChange={setField('baseUrl')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
Leave empty to use same-origin (when served by the Lua server)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="clientId">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
id="clientId"
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="my-client"
|
||||
value={form.clientId}
|
||||
onChange={setField('clientId')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="clientSecret">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
id="clientSecret"
|
||||
className="form-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.clientSecret}
|
||||
onChange={setField('clientSecret')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.kind === 'error' && (
|
||||
<div className="error-banner">
|
||||
<span>⚠</span>
|
||||
<span>{status.msg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.kind === 'ok' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(82, 192, 110, 0.1)',
|
||||
border: '1px solid rgba(82, 192, 110, 0.3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--color-success)',
|
||||
fontSize: '0.875rem',
|
||||
}}>
|
||||
<span>✓</span>
|
||||
<span>Connection verified</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleTest}
|
||||
disabled={isBusy || !isValid}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Testing…
|
||||
</>
|
||||
) : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isBusy || !isValid}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Saving…
|
||||
</>
|
||||
) : 'Save & Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted" style={{ textAlign: 'center', lineHeight: 1.6 }}>
|
||||
Credentials are stored only in your browser's localStorage and
|
||||
are never sent anywhere except the configured API URL.
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
},
|
||||
header: {
|
||||
padding: '24px 20px 0',
|
||||
},
|
||||
logoRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
marginBottom: 32,
|
||||
},
|
||||
logo: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
padding: '0 20px 32px',
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
},
|
||||
};
|
||||
81
contrib/darktable-api/web/vite.config.js
Normal file
81
contrib/darktable-api/web/vite.config.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/images\?/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-images-list',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 5,
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/images\/\d+\/preview/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'image-previews',
|
||||
expiration: {
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/(tags|films)/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-metadata',
|
||||
expiration: {
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 60 * 10,
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: 'darktable Gallery',
|
||||
short_name: 'darktable',
|
||||
description: 'darktable REST API web interface',
|
||||
theme_color: '#0d0d0d',
|
||||
background_color: '#0d0d0d',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user