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:
parent
41410e3079
commit
6bc5fffda2
|
|
@ -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
|
||||
|
|
@ -8,6 +8,9 @@ node_modules
|
|||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local data (SQLite database + uploaded images)
|
||||
/data
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785695615587,
|
||||
"tag": "0000_silly_bushwacker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
|
@ -9,18 +9,26 @@
|
|||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"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": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}).`);
|
||||
}
|
||||
12
src/app.css
12
src/app.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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">
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
|
@ -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'>;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Main from '$lib/Main.svelte';
|
||||
</script>
|
||||
|
||||
<Main></Main>
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import adapter from '@sveltejs/adapter-static';
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
|
|
@ -8,12 +8,8 @@ const config = {
|
|||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter({
|
||||
fallback: 'index.html'
|
||||
})
|
||||
// Node server adapter — the app runs as a Node process (behind nginx).
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
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']
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue