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:
Matic Ivešić 2026-08-02 20:54:06 +02:00
parent 41410e3079
commit 6bc5fffda2
32 changed files with 3241 additions and 147 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# Self-hosted backend configuration. Copy to ".env" and adjust as needed.
DATABASE_PATH=./data/app.db
UPLOADS_DIR=./data/uploads
# Required by adapter-node in production (behind nginx), e.g. https://intelidom.si
# ORIGIN=https://intelidom.si

3
.gitignore vendored
View File

@ -8,6 +8,9 @@ node_modules
/.svelte-kit /.svelte-kit
/build /build
# Local data (SQLite database + uploaded images)
/data
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

119
ADMIN_SETUP.md Normal file
View File

@ -0,0 +1,119 @@
# Admin dashboard & gallery — self-hosted setup
The site is a **SvelteKit app running on Node** (`adapter-node`) with a local
**SQLite** database and image files stored on disk. It's meant to run on your
own server behind **nginx**. No third-party services.
- Database: SQLite file at `DATABASE_PATH` (default `./data/app.db`)
- Uploaded images: files under `UPLOADS_DIR` (default `./data/uploads`)
- Auth: server-side sessions (httpOnly cookie), passwords hashed with argon2
- Roles: `admin` (manage users + content) and `editor` (content only)
> The `data/` folder (database + uploads) is git-ignored — it lives only on the
> machine that runs the app.
## Local development
```sh
npm install
npm run db:migrate # create ./data/app.db with the tables
npm run create-admin -- --email you@example.com --password "yourpassword" --name "Ime" --role admin
npm run dev
```
Then open <http://localhost:5173/admin>, log in, and manage the gallery. The
public gallery is at `/gallery` (also linked from the header).
## Production (your home server)
### 1. Build
```sh
npm install
npm run build # outputs the Node server to ./build
```
### 2. Environment variables
| Variable | Purpose | Example |
|---|---|---|
| `ORIGIN` | **Required.** Public URL, for correct cookies/CSRF behind nginx | `https://intelidom.si` |
| `PORT` | Port the Node app listens on | `3000` |
| `BODY_SIZE_LIMIT` | **Max upload size** — the default is only 512 KB, too small for photos | `52428800` (50 MB) |
| `DATABASE_PATH` | SQLite file location | `/srv/intelidom/data/app.db` |
| `UPLOADS_DIR` | Uploaded images directory | `/srv/intelidom/data/uploads` |
> `BODY_SIZE_LIMIT` matters: without raising it, uploading a normal phone photo
> fails with **413 Payload Too Large**.
### 3. Initialise the database + first admin
```sh
npm run db:migrate # uses DATABASE_PATH
npm run create-admin -- --email you@example.com --password "strongpassword" --role admin
```
(Migrations also run automatically on app startup, so this is mainly to seed the
first admin before the app runs. Additional users can be added the same way, or
via the dashboard once Phase 2 lands.)
### 4. Run it
Example `systemd` service (`/etc/systemd/system/intelidom.service`):
```ini
[Service]
WorkingDirectory=/srv/intelidom
ExecStart=/usr/bin/node build
Environment=ORIGIN=https://intelidom.si
Environment=PORT=3000
Environment=BODY_SIZE_LIMIT=52428800
Environment=DATABASE_PATH=/srv/intelidom/data/app.db
Environment=UPLOADS_DIR=/srv/intelidom/data/uploads
Restart=always
User=www-data
[Install]
WantedBy=multi-user.target
```
`sudo systemctl enable --now intelidom` to start it.
### 5. nginx
Reverse-proxy to the Node app and let nginx serve uploaded images directly:
```nginx
server {
server_name intelidom.si;
# ... your TLS certs ...
client_max_body_size 50m; # must be >= BODY_SIZE_LIMIT for uploads
location /uploads/ {
alias /srv/intelidom/data/uploads/;
expires 30d;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 6. Backups
Everything worth backing up is in the data directory — back it up regularly:
```sh
cp -r /srv/intelidom/data /backups/intelidom-$(date +%F)
```
## Migrating the old Supabase gallery
The previous version stored gallery images in Supabase. There's no automatic
import — simply **re-upload** those images through the new `/admin` dashboard.
Keep the old Supabase project until you've done so, then it can be deleted.

10
drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_PATH ?? './data/app.db'
}
});

View File

@ -0,0 +1,25 @@
CREATE TABLE `gallery_images` (
`id` text PRIMARY KEY NOT NULL,
`image_path` text NOT NULL,
`caption` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'editor' NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

@ -0,0 +1,170 @@
{
"version": "6",
"dialect": "sqlite",
"id": "937f8d50-c54c-47ab-8b71-933736e4cfdb",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"gallery_images": {
"name": "gallery_images",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editor'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785695615587,
"tag": "0000_silly_bushwacker",
"breakpoints": true
}
]
}

1985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,18 +9,26 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"create-admin": "node scripts/create-admin.js"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.43.2", "@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/better-sqlite3": "^9.6.0",
"drizzle-kit": "^0.31.10",
"svelte": "^5.39.5", "svelte": "^5.39.5",
"svelte-check": "^4.3.2", "svelte-check": "^4.3.2",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.7" "vite": "^7.1.7"
}, },
"dependencies": { "dependencies": {
"@sveltejs/adapter-static": "^3.0.10" "@node-rs/argon2": "^2.0.2",
"@sveltejs/adapter-node": "^5.5.7",
"better-sqlite3": "^13.0.2",
"drizzle-orm": "^0.45.2"
} }
} }

57
scripts/create-admin.js Normal file
View File

@ -0,0 +1,57 @@
// Create (or update) a dashboard user directly in the SQLite database.
// Run the migrations first (npm run db:migrate), then:
// npm run create-admin -- --email you@example.com --password "secret" --name "Ime" --role admin
// Values can also come from ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_NAME / ADMIN_ROLE env vars.
import Database from 'better-sqlite3';
import { hash } from '@node-rs/argon2';
import { randomUUID } from 'node:crypto';
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 ? process.argv[i + 1] : undefined;
}
const email = (arg('email') ?? process.env.ADMIN_EMAIL ?? '').trim().toLowerCase();
const password = arg('password') ?? process.env.ADMIN_PASSWORD ?? '';
const name = arg('name') ?? process.env.ADMIN_NAME ?? 'Admin';
const role = arg('role') ?? process.env.ADMIN_ROLE ?? 'admin';
const dbPath = process.env.DATABASE_PATH ?? './data/app.db';
if (!email || !password) {
console.error(
'Usage: npm run create-admin -- --email you@example.com --password "secret" [--name "Ime"] [--role admin|editor]'
);
process.exit(1);
}
if (role !== 'admin' && role !== 'editor') {
console.error('role must be "admin" or "editor"');
process.exit(1);
}
const db = new Database(dbPath);
const hasUsersTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
.get();
if (!hasUsersTable) {
console.error('No "users" table found. Run "npm run db:migrate" first.');
process.exit(1);
}
const passwordHash = await hash(password);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, name = ?, role = ? WHERE email = ?').run(
passwordHash,
name,
role,
email
);
console.log(`Updated existing user ${email} (role: ${role}).`);
} else {
db.prepare(
'INSERT INTO users (id, email, password_hash, name, role, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(randomUUID(), email, passwordHash, name, role, Date.now());
console.log(`Created user ${email} (role: ${role}).`);
}

View File

@ -8,4 +8,16 @@ body {
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: 2em; 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;
} }

7
src/app.d.ts vendored
View File

@ -1,9 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
import type { User, Session } from '$lib/server/db/schema';
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} interface Locals {
user: User | null;
session: Session | null;
}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

32
src/hooks.server.ts Normal file
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);
};

View File

@ -4,7 +4,7 @@
</script> </script>
<style> <style>
div{ .header-content{
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -18,11 +18,40 @@
z-index: 1; z-index: 1;
} }
div>img{ .header-content img{
padding: 1em; padding: 1em;
width: 15em; width: 15em;
z-index: 1; 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){ @media (min-width: 900px){
:global(.header-contact){ :global(.header-contact){
@ -31,7 +60,13 @@
z-index: 1; z-index: 1;
} }
div{ .nav{
position: absolute;
left: 2em;
top: 1.5em;
}
.header-content{
padding: 0; padding: 0;
} }
} }
@ -40,6 +75,11 @@
<div class="header-content"> <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/> <HeaderContact/>
</div> </div>

View File

@ -1,7 +1,10 @@
<script> <script>
import HeaderContactElement from "./HeaderContactElement.svelte"; import HeaderContactElement from "./HeaderContactElement.svelte";
import phone_icon from '/phone.svg' // Files in static/ are served at the site root — reference them by URL,
import mail_icon from '/mail.svg' // 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 = ''; export let customClass = '';
</script> </script>

View File

@ -25,7 +25,7 @@ li{
<li>Odpiranje vrat na pin kodo ali kartico</li> <li>Odpiranje vrat na pin kodo ali kartico</li>
</ul> </ul>
</Service> </Service>
<Service service='Telekomunikacije' serviceImage='/telecommunications_image.png' imageOnRight=true> <Service service='Telekomunikacije' serviceImage='/telecommunications_image.png' imageOnRight={true}>
<ul> <ul>
<li>Napeljava podatkovnih kablov za računalniška omrežja</li> <li>Napeljava podatkovnih kablov za računalniška omrežja</li>
<li>Montaža komunikacijskih omaric</li> <li>Montaža komunikacijskih omaric</li>

65
src/lib/server/auth.ts Normal file
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();
}

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' });

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
src/lib/server/storage.ts Normal file
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;
}

View File

@ -1,16 +1,10 @@
<script lang="ts"> <script lang="ts">
import intelidom_icon from '$lib/assets/intelidom_icon.svg'; 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 Header from '$lib/Header.svelte';
import HeaderContact from '$lib/HeaderContact.svelte'; import Footer from '$lib/Footer.svelte';
import HeaderContactElement from "$lib/HeaderContactElement.svelte";
import Main from '$lib/Main.svelte'
import Footer from '$lib/Footer.svelte'
let { children } = $props(); let { children } = $props();
</script> </script>
@ -21,7 +15,9 @@
</svelte:head> </svelte:head>
<Header></Header> <Header></Header>
<Main></Main>
<Footer></Footer>
{@render children?.()} <div class="app-content">
{@render children?.()}
</div>
<Footer></Footer>

View File

@ -0,0 +1,5 @@
<script lang="ts">
import Main from '$lib/Main.svelte';
</script>
<Main></Main>

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

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');
}
};

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>

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');
}
};

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>

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

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>

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'
}
});
};

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
@ -8,12 +8,8 @@ const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // Node server adapter — the app runs as a Node process (behind nginx).
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. adapter: adapter()
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html'
})
} }
}; };

View File

@ -2,5 +2,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
// better-sqlite3 is a native module — keep it external so Vite/Rollup
// never tries to bundle the .node binary (dev SSR and the server build).
ssr: {
external: ['better-sqlite3']
},
optimizeDeps: {
exclude: ['better-sqlite3']
}
}); });