Phase 1: self-hosted backend (adapter-node + SQLite) replacing Supabase

Migrate the gallery/admin off the static + Supabase setup to a self-hosted
full-stack app that runs on the home server behind nginx.

- Switch adapter-static -> adapter-node
- SQLite via Drizzle (users, sessions, gallery_images); migrations run on startup
- Server-side session auth (argon2 hashing, httpOnly cookie, SHA-256 stored token,
  sliding expiry) wired through hooks.server.ts
- Admin: server-side login, route guard, gallery upload/delete via form actions
- Public gallery is now SSR from the DB; /uploads/[...path] serves images in dev
- scripts/create-admin.js + db:generate/db:migrate/create-admin npm scripts
- Remove @supabase/supabase-js and the client-side gallery/admin code
- Fix pre-existing imageOnRight boolean prop type in Main.svelte
- ADMIN_SETUP.md rewritten for local dev + home-server deploy (systemd, nginx, backups)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MaticIvesicandClaude Opus 4.8 committed 2026-08-02 20:54:06 +02:00
1 parent 41410e3079
commit 6bc5fffda2
32 files changed
+3241 -147

No files matched your search

+14 -2
View File
@@ -1,6 +1,6 @@
body {
background-color: #0a0d0f;
color: #e5e4ea;
background-color: #0a0d0f;
color: #e5e4ea;
font-family: "Inter", sans-serif;
margin: 0;
padding: 0;
@@ -8,4 +8,16 @@ body {
align-items: center;
flex-direction: column;
gap: 2em;
min-height: 100vh;
box-sizing: border-box;
}
/* Grow the page content so the footer is always pushed to the bottom,
even when a page has very little content. */
.app-content {
flex: 1 0 auto;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
+6 -1
View File
@@ -1,9 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { User, Session } from '$lib/server/db/schema';
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: User | null;
session: Session | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+32
View File
@@ -0,0 +1,32 @@
import type { Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const result = validateSessionToken(token);
if (result) {
event.locals.user = result.user;
event.locals.session = result.session;
// Keep the cookie in sync with the (possibly refreshed) session expiry.
event.cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: result.session.expiresAt
});
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
event.locals.user = null;
event.locals.session = null;
}
} else {
event.locals.user = null;
event.locals.session = null;
}
return resolve(event);
};
+45 -5
View File
@@ -4,7 +4,7 @@
</script>
<style>
div{
.header-content{
width: 100%;
display: flex;
flex-direction: column;
@@ -18,11 +18,40 @@
z-index: 1;
}
div>img{
.header-content img{
padding: 1em;
width: 15em;
z-index: 1;
}
.nav{
display: flex;
gap: 1.5em;
padding: 0.5em 0;
z-index: 1;
}
.nav-link{
color: #e5e4ea;
text-decoration: none;
font-weight: 500;
letter-spacing: 0.02em;
padding: 0.35em 0.9em;
border: 1px solid rgba(229, 228, 234, 0.25);
border-radius: 999px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.nav-link:hover{
background-color: rgba(66, 175, 56, 0.15);
border-color: rgba(66, 175, 56, 0.8);
color: #ffffff;
}
.banner-link{
display: inline-flex;
}
@media (min-width: 900px){
:global(.header-contact){
@@ -31,7 +60,13 @@
z-index: 1;
}
div{
.nav{
position: absolute;
left: 2em;
top: 1.5em;
}
.header-content{
padding: 0;
}
}
@@ -40,6 +75,11 @@
<div class="header-content">
<img src={intelidom_banner} alt="intelidom banner">
<nav class="nav">
<a class="nav-link" href="/gallery">Galerija</a>
</nav>
<a class="banner-link" href="/" aria-label="Domov">
<img src={intelidom_banner} alt="intelidom banner">
</a>
<HeaderContact/>
</div>
</div>
+5 -2
View File
@@ -1,7 +1,10 @@
<script>
import HeaderContactElement from "./HeaderContactElement.svelte";
import phone_icon from '/phone.svg'
import mail_icon from '/mail.svg'
// Files in static/ are served at the site root — reference them by URL,
// don't import them as modules (Vite dev serves them as raw SVG, which
// breaks the import and blanks the whole layout).
const phone_icon = '/phone.svg';
const mail_icon = '/mail.svg';
export let customClass = '';
</script>
+1 -1
View File
@@ -25,7 +25,7 @@ li{
<li>Odpiranje vrat na pin kodo ali kartico</li>
</ul>
</Service>
<Service service='Telekomunikacije' serviceImage='/telecommunications_image.png' imageOnRight=true>
<Service service='Telekomunikacije' serviceImage='/telecommunications_image.png' imageOnRight={true}>
<ul>
<li>Napeljava podatkovnih kablov za računalniška omrežja</li>
<li>Montaža komunikacijskih omaric</li>
+65
View File
@@ -0,0 +1,65 @@
import { hash, verify } from '@node-rs/argon2';
import { randomBytes, createHash } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { db } from './db';
import { sessions, users, type Session, type User } from './db/schema';
export const SESSION_COOKIE = 'session';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
export function hashPassword(password: string): Promise<string> {
return hash(password);
}
export function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password);
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
/** Create a session for a user and return the raw token to store in the cookie. */
export function createSession(userId: string): { token: string; expiresAt: Date } {
const token = randomBytes(32).toString('hex');
const id = sha256(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.insert(sessions).values({ id, userId, expiresAt }).run();
return { token, expiresAt };
}
/** Look up the session/user for a cookie token, refreshing expiry as it nears the end. */
export function validateSessionToken(token: string): { user: User; session: Session } | null {
const id = sha256(token);
const row = db
.select()
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, id))
.get();
if (!row) return null;
const { sessions: session, users: user } = row;
if (Date.now() >= session.expiresAt.getTime()) {
db.delete(sessions).where(eq(sessions.id, id)).run();
return null;
}
// Sliding expiration: extend when less than half the lifetime remains.
if (Date.now() >= session.expiresAt.getTime() - SESSION_TTL_MS / 2) {
session.expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.update(sessions).set({ expiresAt: session.expiresAt }).where(eq(sessions.id, id)).run();
}
return { user, session };
}
export function invalidateSession(token: string): void {
db.delete(sessions).where(eq(sessions.id, sha256(token))).run();
}
export function findUserByEmail(email: string): User | undefined {
return db.select().from(users).where(eq(users.email, email.toLowerCase())).get();
}
+19
View File
@@ -0,0 +1,19 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { env } from '$env/dynamic/private';
import * as schema from './schema';
const dbPath = env.DATABASE_PATH ?? './data/app.db';
mkdirSync(dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
// Apply any pending migrations on startup so a fresh deploy self-initialises.
migrate(db, { migrationsFolder: './drizzle' });
+43
View File
@@ -0,0 +1,43 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
/** Admin/editor accounts that can sign in to the dashboard. */
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
// 'admin' can manage users + all content; 'editor' can manage content only.
role: text('role', { enum: ['admin', 'editor'] })
.notNull()
.default('editor'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});
/** Server-side sessions. `id` is the SHA-256 of the cookie token (never the raw token). */
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull()
});
/** Gallery images. `imagePath` is relative to the uploads dir, e.g. "gallery/uuid.jpg". */
export const galleryImages = sqliteTable('gallery_images', {
id: text('id').primaryKey(),
imagePath: text('image_path').notNull(),
caption: text('caption'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});
export type User = typeof users.$inferSelect;
export type Session = typeof sessions.$inferSelect;
export type GalleryImage = typeof galleryImages.$inferSelect;
/** User shape safe to expose to the browser (no password hash). */
export type PublicUser = Pick<User, 'id' | 'email' | 'name' | 'role'>;
+40
View File
@@ -0,0 +1,40 @@
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { join, dirname, resolve, sep } from 'node:path';
import { randomUUID } from 'node:crypto';
import { env } from '$env/dynamic/private';
/** Root directory for user-uploaded files (served at /uploads, or by nginx in prod). */
export const UPLOADS_DIR = env.UPLOADS_DIR ?? './data/uploads';
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif']);
/** Save an uploaded image under `<subdir>/` and return its path relative to UPLOADS_DIR. */
export async function saveImage(subdir: string, file: File): Promise<string> {
const ext = (file.name.split('.').pop() ?? '').toLowerCase();
const safeExt = IMAGE_EXTENSIONS.has(ext) ? ext : 'jpg';
const relativePath = `${subdir}/${randomUUID()}.${safeExt}`;
const absolutePath = join(UPLOADS_DIR, relativePath);
mkdirSync(dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, Buffer.from(await file.arrayBuffer()));
return relativePath;
}
export function deleteUpload(relativePath: string): void {
try {
unlinkSync(resolveUploadPath(relativePath));
} catch {
// Already gone — nothing to do.
}
}
/** Resolve a relative upload path to an absolute one, guarding against path traversal. */
export function resolveUploadPath(relativePath: string): string {
const root = resolve(UPLOADS_DIR);
const target = resolve(root, relativePath);
if (target !== root && !target.startsWith(root + sep)) {
throw new Error('Invalid upload path');
}
return target;
}
+8 -12
View File
@@ -1,16 +1,10 @@
<script lang="ts">
import intelidom_icon from '$lib/assets/intelidom_icon.svg';
import intelidom_banner from '$lib/assets/intelidom_banner.svg';
import '../app.css';
import '../app.css';
import Header from '$lib/Header.svelte';
import HeaderContact from '$lib/HeaderContact.svelte';
import HeaderContactElement from "$lib/HeaderContactElement.svelte";
import Main from '$lib/Main.svelte'
import Footer from '$lib/Footer.svelte'
import Footer from '$lib/Footer.svelte';
let { children } = $props();
</script>
@@ -21,7 +15,9 @@
</svelte:head>
<Header></Header>
<Main></Main>
<Footer></Footer>
{@render children?.()}
<div class="app-content">
{@render children?.()}
</div>
<Footer></Footer>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import Main from '$lib/Main.svelte';
</script>
<Main></Main>
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => {
const onLogin = url.pathname === '/admin/login';
if (!locals.user && !onLogin) {
throw redirect(303, '/admin/login');
}
if (locals.user && onLogin) {
throw redirect(303, '/admin');
}
return {
user: locals.user
? {
id: locals.user.id,
email: locals.user.email,
name: locals.user.name,
role: locals.user.role
}
: null
};
};
+68
View File
@@ -0,0 +1,68 @@
import { fail, redirect } from '@sveltejs/kit';
import { asc, desc, eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { galleryImages } from '$lib/server/db/schema';
import { saveImage, deleteUpload } from '$lib/server/storage';
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
function listImages() {
return db
.select()
.from(galleryImages)
.orderBy(asc(galleryImages.sortOrder), desc(galleryImages.createdAt))
.all()
.map((image) => ({
id: image.id,
url: `/uploads/${image.imagePath}`,
caption: image.caption
}));
}
export const load: PageServerLoad = () => {
return { images: listImages() };
};
export const actions: Actions = {
upload: async ({ request }) => {
const form = await request.formData();
const caption = String(form.get('caption') ?? '').trim() || null;
const files = form
.getAll('images')
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (files.length === 0) {
return fail(400, { error: 'Izberite vsaj eno sliko.' });
}
let count = 0;
for (const file of files) {
const imagePath = await saveImage('gallery', file);
db.insert(galleryImages).values({ id: randomUUID(), imagePath, caption }).run();
count++;
}
return { success: `Naloženih slik: ${count}.` };
},
delete: async ({ request }) => {
const form = await request.formData();
const id = String(form.get('id') ?? '');
const row = db.select().from(galleryImages).where(eq(galleryImages.id, id)).get();
if (row) {
deleteUpload(row.imagePath);
db.delete(galleryImages).where(eq(galleryImages.id, id)).run();
}
return { success: 'Slika izbrisana.' };
},
logout: async ({ cookies }) => {
const token = cookies.get(SESSION_COOKIE);
if (token) invalidateSession(token);
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/admin/login');
}
};
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<svelte:head>
<title>Admin — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<section class="admin">
<div class="bar">
<h1>Admin</h1>
<div class="bar-right">
{#if data.user}<span>Prijavljeni kot <strong>{data.user.name}</strong></span>{/if}
<form method="POST" action="?/logout" use:enhance>
<button class="ghost" type="submit">Odjava</button>
</form>
</div>
</div>
<form
class="card upload"
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Dodaj sliko v galerijo</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<label>
Opis (neobvezno)
<input type="text" name="caption" placeholder="npr. Montaža omarice" />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
{#if form?.success}<p class="msg">{form.success}</p>{/if}
<button type="submit" disabled={uploading}>{uploading ? 'Nalaganje…' : 'Naloži'}</button>
</form>
<h2 class="section-title">Slike v galeriji</h2>
{#if data.images.length === 0}
<p class="notice">Ni slik. Naložite prvo zgoraj.</p>
{:else}
<div class="grid">
{#each data.images as image (image.id)}
<div class="tile">
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={image.id} />
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<style>
.admin {
width: 100%;
max-width: 900px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
gap: 1.2em;
}
h1 {
margin: 0;
font-weight: 600;
}
h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
.notice {
color: #9aa0a6;
}
.bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
flex-wrap: wrap;
color: #c7c9cf;
}
.bar-right {
display: flex;
align-items: center;
gap: 1em;
}
.card {
background: #11161a;
border: 1px solid rgba(229, 228, 234, 0.12);
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
}
label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
input[type='text'] {
background: #0a0d0f;
border: 1px solid rgba(229, 228, 234, 0.2);
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
}
input[type='file'] {
color: #c7c9cf;
font-family: inherit;
}
button {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.6em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 1em;
cursor: pointer;
align-self: flex-start;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
button.ghost {
background: transparent;
color: #e5e4ea;
border: 1px solid rgba(229, 228, 234, 0.3);
}
.section-title {
margin-top: 0.5em;
}
.error {
color: #ff6b6b;
margin: 0;
font-size: 0.9em;
}
.msg {
color: #42af38;
margin: 0;
font-size: 0.9em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1em;
}
.tile {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile .cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.5em 0.6em;
font-size: 0.8em;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.tile .del {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 40, 40, 0.9);
color: #fff;
padding: 0.3em 0.6em;
font-size: 0.8em;
border-radius: 6px;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import type { Actions } from './$types';
import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const email = String(form.get('email') ?? '')
.trim()
.toLowerCase();
const password = String(form.get('password') ?? '');
if (!email || !password) {
return fail(400, { error: 'Vnesite e-naslov in geslo.', email });
}
const user = findUserByEmail(email);
if (!user || !(await verifyPassword(user.passwordHash, password))) {
return fail(400, { error: 'Napačen e-naslov ali geslo.', email });
}
const { token, expiresAt } = createSession(user.id);
cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: expiresAt
});
throw redirect(303, '/admin');
}
};
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let submitting = $state(false);
</script>
<svelte:head>
<title>Prijava — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<section class="login-wrap">
<form
class="card login"
method="POST"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<h2>Prijava</h2>
<label>
E-naslov
<input type="email" name="email" value={form?.email ?? ''} autocomplete="username" required />
</label>
<label>
Geslo
<input type="password" name="password" autocomplete="current-password" required />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
<button type="submit" disabled={submitting}>{submitting ? 'Prijavljanje…' : 'Prijava'}</button>
</form>
</section>
<style>
.login-wrap {
flex: 1 0 auto;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 2em 1em;
}
.card {
background: #11161a;
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
width: 360px;
max-width: 100%;
}
h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
input {
background: #0a0d0f;
border: 1px solid rgba(229, 228, 234, 0.2);
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
}
button {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.6em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 1em;
cursor: pointer;
align-self: flex-start;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.error {
color: #ff6b6b;
margin: 0;
font-size: 0.9em;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import { asc, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { galleryImages } from '$lib/server/db/schema';
export const load: PageServerLoad = () => {
const images = db
.select()
.from(galleryImages)
.orderBy(asc(galleryImages.sortOrder), desc(galleryImages.createdAt))
.all()
.map((image) => ({
id: image.id,
url: `/uploads/${image.imagePath}`,
caption: image.caption
}));
return { images };
};
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type GalleryImage = PageData['images'][number];
let selected: GalleryImage | null = $state(null);
</script>
<svelte:head>
<title>Galerija — InteliDom d.o.o.</title>
</svelte:head>
<section class="gallery">
<h1>Galerija</h1>
{#if data.images.length === 0}
<p class="notice">Galerija je trenutno prazna.</p>
{:else}
<div class="grid">
{#each data.images as img (img.id)}
<button class="tile" onclick={() => (selected = img)} aria-label="Povečaj sliko">
<img src={img.url} alt={img.caption ?? 'Slika iz galerije'} loading="lazy" />
{#if img.caption}
<span class="caption">{img.caption}</span>
{/if}
</button>
{/each}
</div>
{/if}
</section>
{#if selected}
<div
class="lightbox"
role="button"
tabindex="0"
aria-label="Zapri"
onclick={() => (selected = null)}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') selected = null;
}}
>
<img src={selected.url} alt={selected.caption ?? 'Slika iz galerije'} />
{#if selected.caption}
<p class="lightbox-caption">{selected.caption}</p>
{/if}
</div>
{/if}
<style>
.gallery {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5em;
}
h1 {
font-weight: 600;
margin: 0;
}
.notice {
color: #9aa0a6;
}
.grid {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
}
.tile {
position: relative;
padding: 0;
border: none;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.35s ease;
}
.tile:hover img {
transform: scale(1.06);
}
.caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.6em 0.8em;
font-size: 0.85em;
color: #fff;
text-align: left;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
}
.lightbox {
position: fixed;
inset: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1em;
padding: 2em;
cursor: zoom-out;
}
.lightbox img {
max-width: 92vw;
max-height: 82vh;
object-fit: contain;
border-radius: 8px;
}
.lightbox-caption {
color: #e5e4ea;
margin: 0;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
import { error } from '@sveltejs/kit';
import { readFileSync, statSync } from 'node:fs';
import { extname } from 'node:path';
import type { RequestHandler } from './$types';
import { resolveUploadPath } from '$lib/server/storage';
// In production nginx serves /uploads/* straight off disk; this endpoint is the
// dev fallback (and a safety net) so uploaded images resolve without nginx.
const CONTENT_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif'
};
export const GET: RequestHandler = ({ params }) => {
let absolutePath: string;
try {
absolutePath = resolveUploadPath(params.path);
} catch {
throw error(400, 'Invalid path');
}
let data: Buffer;
try {
statSync(absolutePath);
data = readFileSync(absolutePath);
} catch {
throw error(404, 'Not found');
}
const type = CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream';
return new Response(new Uint8Array(data), {
headers: {
'Content-Type': type,
'Cache-Control': 'public, max-age=2592000'
}
});
};