diff --git a/.gitignore b/.gitignore index 4c12cea..f54cbf6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ npm-debug.log* # Postgres data volume bij lokaal draaien pgdata/ +storage/ # OS / editor .DS_Store diff --git a/Dockerfile b/Dockerfile index b2b6aba..65d5366 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV NODE_ENV=production WORKDIR /app # Draai als non-root gebruiker -RUN addgroup -S app && adduser -S app -G app +RUN addgroup -S app && adduser -S app -G app && mkdir -p /app/storage/images && chown -R app:app /app/storage COPY --chown=app:app --from=deps /app/node_modules ./node_modules COPY --chown=app:app package.json ./ diff --git a/VERSION b/VERSION index 989d52f..1ea7270 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.54-beta +0.3.55-beta diff --git a/compose.yaml b/compose.yaml index 4764b57..b8f0e71 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,8 @@ services: COOKIE_SECURE: "false" ports: - "3000:3000" + volumes: + - image_data:/app/storage/images restart: unless-stopped db: @@ -43,3 +45,4 @@ services: volumes: pgdata: + image_data: diff --git a/db/012_image_catalog.sql b/db/012_image_catalog.sql new file mode 100644 index 0000000..cd026e3 --- /dev/null +++ b/db/012_image_catalog.sql @@ -0,0 +1,130 @@ +-- v0.3.55-beta: doorzoekbare afbeeldingscatalogus met meervoudige thema's, +-- map-paden, eigenaarschap en instelbare opslagquota. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT + CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1); +ALTER TABLE schools + ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT + CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1); + +CREATE TABLE IF NOT EXISTS image_themes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key TEXT UNIQUE, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + folder TEXT NOT NULL DEFAULT '', + name_nl TEXT NOT NULL, + name_en TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_global_name + ON image_themes (lower(name_nl)) WHERE scope = 'global'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_school_name + ON image_themes (school_id, lower(name_nl)) WHERE scope = 'school'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_personal_name + ON image_themes (owner_id, lower(name_nl)) WHERE scope = 'personal'; + +CREATE TABLE IF NOT EXISTS image_folders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_global_path + ON image_folders (path) WHERE scope = 'global'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_school_path + ON image_folders (school_id, path) WHERE scope = 'school'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_personal_path + ON image_folders (owner_id, path) WHERE scope = 'personal'; + +CREATE TABLE IF NOT EXISTS image_assets ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + folder TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + original_name TEXT, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + storage_key TEXT UNIQUE, + public_path TEXT UNIQUE, + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ((storage_key IS NULL) <> (public_path IS NULL)), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE INDEX IF NOT EXISTS idx_image_assets_owner ON image_assets(owner_id); +CREATE INDEX IF NOT EXISTS idx_image_assets_school ON image_assets(school_id); +CREATE INDEX IF NOT EXISTS idx_image_assets_folder ON image_assets(scope, folder); + +CREATE TABLE IF NOT EXISTS image_asset_themes ( + asset_id BIGINT NOT NULL REFERENCES image_assets(id) ON DELETE CASCADE, + theme_id BIGINT NOT NULL REFERENCES image_themes(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, theme_id) +); +CREATE INDEX IF NOT EXISTS idx_image_asset_themes_theme ON image_asset_themes(theme_id); + +INSERT INTO image_themes (key, scope, name_nl, name_en) +VALUES + ('builtin-money', 'global', 'Geld', 'Money'), + ('builtin-coins', 'global', 'Munten', 'Coins'), + ('builtin-banknotes', 'global', 'Briefgeld', 'Banknotes') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO image_folders (scope, path) +VALUES ('global','Rekenen'), ('global','Rekenen/Geld'), + ('global','Rekenen/Geld/Munten'), ('global','Rekenen/Geld/Briefgeld') +ON CONFLICT DO NOTHING; + +INSERT INTO image_assets (key, scope, folder, name, mime_type, public_path, tags) +VALUES + ('builtin-money-coin-1', 'global', 'Rekenen/Geld/Munten', '1 cent', 'image/png', '/img/money/coin-1.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-2', 'global', 'Rekenen/Geld/Munten', '2 cent', 'image/png', '/img/money/coin-2.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-5', 'global', 'Rekenen/Geld/Munten', '5 cent', 'image/png', '/img/money/coin-5.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-10', 'global', 'Rekenen/Geld/Munten', '10 cent', 'image/png', '/img/money/coin-10.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-20', 'global', 'Rekenen/Geld/Munten', '20 cent', 'image/png', '/img/money/coin-20.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-50', 'global', 'Rekenen/Geld/Munten', '50 cent', 'image/png', '/img/money/coin-50.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-100', 'global', 'Rekenen/Geld/Munten', '1 euro', 'image/png', '/img/money/coin-100.png', ARRAY['geld','money','munt','coin','euro']), + ('builtin-money-coin-200', 'global', 'Rekenen/Geld/Munten', '2 euro', 'image/png', '/img/money/coin-200.png', ARRAY['geld','money','munt','coin','euro']), + ('builtin-money-note-500', 'global', 'Rekenen/Geld/Briefgeld', '5 euro', 'image/jpeg', '/img/money/note-500.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-1000', 'global', 'Rekenen/Geld/Briefgeld', '10 euro', 'image/jpeg', '/img/money/note-1000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-2000', 'global', 'Rekenen/Geld/Briefgeld', '20 euro', 'image/jpeg', '/img/money/note-2000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-5000', 'global', 'Rekenen/Geld/Briefgeld', '50 euro', 'image/jpeg', '/img/money/note-5000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO image_asset_themes (asset_id, theme_id) +SELECT a.id, t.id +FROM image_assets a +JOIN image_themes t ON t.key = 'builtin-money' +WHERE a.key LIKE 'builtin-money-%' +ON CONFLICT DO NOTHING; + +INSERT INTO image_asset_themes (asset_id, theme_id) +SELECT a.id, t.id +FROM image_assets a +JOIN image_themes t ON t.key = CASE + WHEN a.key LIKE 'builtin-money-coin-%' THEN 'builtin-coins' + ELSE 'builtin-banknotes' +END +WHERE a.key LIKE 'builtin-money-%' +ON CONFLICT DO NOTHING; diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 8baf432..c796f0c 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -28,6 +28,8 @@ services: ports: # Alleen op localhost van de VM; nginx zit ervoor als reverse proxy - "127.0.0.1:${APP_PORT:-3000}:3000" + volumes: + - image_data:/app/storage/images restart: unless-stopped web: @@ -60,3 +62,4 @@ services: volumes: pgdata: + image_data: diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 4d42117..319bed8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -10,7 +10,7 @@ server { server_tokens off; # Wat groter zodat grote borden/afbeeldingen niet geweigerd worden - client_max_body_size 25m; + client_max_body_size 55m; location / { proxy_pass http://app:3000; diff --git a/public/css/image-catalog.css b/public/css/image-catalog.css new file mode 100644 index 0000000..2995342 --- /dev/null +++ b/public/css/image-catalog.css @@ -0,0 +1,33 @@ +.image-catalog-modal{width:min(1080px,96vw);max-height:94vh} +.ic-tools{display:grid;grid-template-columns:minmax(180px,1.5fr) repeat(3,minmax(130px,.8fr)) auto;gap:8px;position:sticky;top:-24px;z-index:3;background:var(--surface);padding:8px 0 10px} +.ic-tools input,.ic-tools select,.ic-add select,.ic-add input,.ic-editor input,.ic-editor select{min-width:0;width:100%;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--surface);color:var(--ink);font:inherit} +.ic-quota{padding:8px 12px;margin-bottom:10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:13px;font-weight:800} +.ic-add{border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:10px;background:var(--surface-2)} +.ic-add summary{cursor:pointer;font-weight:900} +.ic-add-grid,.ic-editor-grid{display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:10px;margin-top:10px;align-items:end} +.ic-field{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:800;color:var(--muted)} +.ic-field select[multiple]{min-height:88px} +.ic-upload{display:flex;align-items:center;justify-content:center;min-height:44px;margin:0;cursor:pointer;text-align:center;color:var(--ink)} +.ic-theme-new{display:flex;gap:8px;margin-top:10px}.ic-theme-new input{flex:1} +.ic-status{min-height:20px;font-size:13px;font-weight:800;color:var(--red);padding:2px 0}.ic-status.ok{color:var(--green)} +.ic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px} +.ic-card{position:relative;min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden;transition:border-color .15s,box-shadow .15s} +.ic-card:hover{border-color:var(--accent);box-shadow:var(--shadow-1)} +.ic-choose{display:grid;grid-template-rows:120px auto auto;width:100%;height:100%;gap:5px;padding:10px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer} +.ic-choose img{width:100%;height:120px;object-fit:contain;border-radius:9px;background:#fff} +.ic-choose strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ic-choose span{font-size:11px;color:var(--muted);min-height:28px;overflow:hidden} +.ic-card-edit{position:absolute;right:7px;top:7px;width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer;box-shadow:var(--shadow-1)} +.ic-editor{position:sticky;bottom:-24px;z-index:4;margin-top:12px;padding:14px;border:2px solid var(--accent);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2)} +.ic-editor h3{margin:0}.ic-editor-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:10px} +.ic-explorer-thumb{width:64px;height:56px;object-fit:contain;border-radius:8px;background:#fff} +.img-quota-admin{display:flex;flex-direction:column;gap:7px}.img-quota-row{display:grid;grid-template-columns:minmax(180px,1fr) 130px 130px auto;align-items:center} +.img-quota-users{display:flex;flex-direction:column;gap:6px;margin-top:8px} +body.hc .ic-card,body.hc .ic-add,body.hc .ic-tools input,body.hc .ic-tools select,body.hc .ic-editor{border-color:#000} +@media(max-width:760px){ + .image-catalog-modal{width:100vw;max-height:100vh;height:100vh;border-radius:0;padding:16px} + .ic-tools{top:-16px;grid-template-columns:1fr 1fr;padding-top:4px}.ic-search,.ic-folders{grid-column:1/-1} + .ic-add-grid,.ic-editor-grid{grid-template-columns:1fr}.ic-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .ic-choose{grid-template-rows:96px auto auto}.ic-choose img{height:96px} + .img-quota-row{grid-template-columns:1fr 1fr}.img-quota-row .am-name{grid-column:1/-1} +} +@media(max-width:420px){.ic-grid{grid-template-columns:1fr}.ic-editor-actions{flex-wrap:wrap}.ic-editor-actions .tbtn{flex:1}} diff --git a/public/index.html b/public/index.html index 3cbf74e..099f97e 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ +
@@ -20,6 +21,7 @@ + @@ -267,6 +269,7 @@ +