Compare commits

..

No commits in common. "main" and "development" have entirely different histories.

72 changed files with 292 additions and 6286 deletions

View File

@ -1,6 +0,0 @@
# 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

6
.gitignore vendored
View File

@ -8,9 +8,6 @@ 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
@ -24,6 +21,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Affinity Designer lock files
*~lock~

View File

@ -1,119 +0,0 @@
# 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.

View File

@ -1,10 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,42 +0,0 @@
CREATE TABLE `banner_images` (
`id` text PRIMARY KEY NOT NULL,
`image_path` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `news_posts` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`slug` text NOT NULL,
`excerpt` text,
`body` text DEFAULT '' NOT NULL,
`cover_image_path` text,
`published` integer DEFAULT false NOT NULL,
`published_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `news_posts_slug_unique` ON `news_posts` (`slug`);--> statement-breakpoint
CREATE TABLE `service_images` (
`id` text PRIMARY KEY NOT NULL,
`service_id` text NOT NULL,
`image_path` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`service_id`) REFERENCES `services`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `services` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`description` text,
`bullets` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);

View File

@ -1,170 +0,0 @@
{
"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

@ -1,429 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f9b24229-b6a3-481b-9bb6-5028ef961005",
"prevId": "937f8d50-c54c-47ab-8b71-933736e4cfdb",
"tables": {
"banner_images": {
"name": "banner_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
},
"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": {}
},
"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": {}
},
"news_posts": {
"name": "news_posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"cover_image_path": {
"name": "cover_image_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published": {
"name": "published",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"news_posts_slug_unique": {
"name": "news_posts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"service_images": {
"name": "service_images",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"service_id": {
"name": "service_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"service_images_service_id_services_id_fk": {
"name": "service_images_service_id_services_id_fk",
"tableFrom": "service_images",
"tableTo": "services",
"columnsFrom": [
"service_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"services": {
"name": "services",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bullets": {
"name": "bullets",
"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": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"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

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

1983
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,26 +9,18 @@
"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": {
"@node-rs/argon2": "^2.0.2", "@sveltejs/adapter-static": "^3.0.10"
"@sveltejs/adapter-node": "^5.5.7",
"better-sqlite3": "^13.0.2",
"drizzle-orm": "^0.45.2"
} }
} }

View File

@ -1,57 +0,0 @@
// 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

@ -1,130 +1,11 @@
body { body {
background-color: #0a0d0f; background-color: #0a0d0f;
color: #e5e4ea; color: #e5e4ea;
font-family: "Inter", sans-serif; font-family: sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: flex;
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;
}
/* ---- Shared admin dashboard UI ---- */
.admin-section {
display: flex;
flex-direction: column;
gap: 1.2em;
}
.admin-section h1 {
margin: 0;
font-weight: 600;
font-size: 1.5em;
}
.admin-section h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
.admin-card {
background: #11161a;
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
}
.admin-card label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
.admin-input {
background: #0a0d0f;
border: none;
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
}
.admin-input:focus-visible {
outline: 2px solid rgba(66, 175, 56, 0.6);
outline-offset: 1px;
}
.admin-btn {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.55em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 0.95em;
cursor: pointer;
align-self: flex-start;
}
.admin-btn:disabled {
opacity: 0.6;
cursor: default;
}
.admin-btn-danger {
background: rgba(220, 40, 40, 0.9);
color: #fff;
}
.admin-btn-ghost {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
}
/* Style the native file input to match the rest of the dashboard UI. */
input[type="file"] {
color: #9aa0a6;
font-family: inherit;
font-size: 0.9em;
}
input[type="file"]::file-selector-button {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border: none;
border-radius: 8px;
padding: 0.5em 1em;
margin-right: 0.9em;
font-family: inherit;
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
}
input[type="file"]::file-selector-button:hover {
background: rgba(66, 175, 56, 0.25);
}
.admin-msg {
margin: 0;
font-size: 0.9em;
color: #42af38;
}
.admin-error {
margin: 0;
font-size: 0.9em;
color: #ff6b6b;
}
.admin-notice {
color: #9aa0a6;
} }

7
src/app.d.ts vendored
View File

@ -1,14 +1,9 @@
// 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 {}

View File

@ -3,9 +3,6 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@ -1,40 +0,0 @@
import { redirect, 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;
}
// Protect the whole dashboard here (except the login page) so that page
// loads, form actions AND endpoints are all gated — layout `load` guards do
// not run before form actions.
const { pathname } = event.url;
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && !event.locals.user) {
throw redirect(303, '/admin/login');
}
return resolve(event);
};

View File

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import intelidom_banner from '$lib/assets/intelidom_banner.svg'; import intelidom_banner from '$lib/assets/intelidom_banner.svg';
import gallery_icon from '$lib/assets/gallery_icon.svg';
import HeaderContact from './HeaderContact.svelte'; import HeaderContact from './HeaderContact.svelte';
</script> </script>
<style> <style>
.header-content{ div{
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -16,18 +15,26 @@
top: 0; top: 0;
backdrop-filter: blur(7px); backdrop-filter: blur(7px);
padding: 0 0 1em 0; padding: 0 0 1em 0;
z-index: 1;
} }
.banner-link img{
.blur-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background-color: rgba(15, 15, 16, 0.8);
}
div>img{
padding: 1em; padding: 1em;
width: 15em; width: 15em;
z-index: 1; z-index: 1;
} }
.banner-link{
display: inline-flex;
}
@media (min-width: 900px){ @media (min-width: 900px){
@ -37,7 +44,7 @@
z-index: 1; z-index: 1;
} }
.header-content{ div{
padding: 0; padding: 0;
} }
} }
@ -46,8 +53,6 @@
<div class="header-content"> <div class="header-content">
<a class="banner-link" href="/" aria-label="Domov">
<img src={intelidom_banner} alt="intelidom banner"> <img src={intelidom_banner} alt="intelidom banner">
</a>
<HeaderContact/> <HeaderContact/>
</div> </div>

View File

@ -1,10 +1,7 @@
<script> <script>
import HeaderContactElement from "./HeaderContactElement.svelte"; import HeaderContactElement from "./HeaderContactElement.svelte";
// Files in static/ are served at the site root — reference them by URL, import phone_icon from '$lib/assets/phone.svg'
// don't import them as modules (Vite dev serves them as raw SVG, which import mail_icon from '$lib/assets/mail.svg'
// 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>
@ -20,12 +17,12 @@ export let customClass = '';
.header-contact{ .header-contact{
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0em; gap: 0.5em;
} }
} }
</style> </style>
<div class="header-contact {customClass}" > <div class="header-contact {customClass}" >
<HeaderContactElement image_src={mail_icon} link="mailto:info@intelidom.si" text="info@intelidom.si"></HeaderContactElement> <HeaderContactElement image_src={mail_icon} image_alt="Mail icon" link="mailto:info@intelidom.si" text="info@intelidom.si"></HeaderContactElement>
<HeaderContactElement image_src={phone_icon} link="tel:+3860771166" text="040 77 11 66"></HeaderContactElement> <HeaderContactElement image_src={phone_icon} image_alt="Mail icon" link="tel:+3860771166" text="040 77 11 66"></HeaderContactElement>
</div> </div>

View File

@ -1,11 +1,10 @@
<script> <script>
export let image_src; export let image_src;
export let image_alt;
export let link; export let link;
export let text; export let text;
export let icon_color = '#bcc0c2'; export let icon_color = '#bcc0c2';
export let text_color = '#bcc0c2'; export let text_color = '#bcc0c2';
import SvgImage from "./SvgImage.svelte";
</script> </script>
<style> <style>
@ -13,15 +12,10 @@
display: flex; display: flex;
gap: 0.75em; gap: 0.75em;
align-items: center; align-items: center;
padding: 0.3em;
border-radius: 0.5em;
transition: 0.2s;
} }
div:hover{ div:hover{
cursor: pointer; cursor: pointer;
background-color: rgba(255, 255, 255, 0.055);
transition: 0.2s;
} }
a{ a{
@ -30,16 +24,15 @@
font-weight: bold; font-weight: bold;
} }
div{ img{
display: flex; width: 1.5em;
gap: 0.75em; height: 1.5em;
align-items: center; filter: drop-shadow(0 0 0 var(--icon-color));
} }
</style> </style>
<div> <div>
<SvgImage image_src={image_src} icon_color={icon_color} /> <img src={image_src} alt={image_alt} style="--icon-color: {icon_color}">
<a style="--text-color: {text_color}" href={link}>{text}</a> <a style="--text-color: {text_color}" href={link}>{text}</a>
</div> </div>

41
src/lib/Main.svelte Normal file
View File

@ -0,0 +1,41 @@
<script>
import Service from '$lib/Service.svelte';
</script>
<style>
.main{
max-width: 1000px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2em;
}
</style>
<div class="main">
<Service service='Pametne inštalacije' serviceImage='/loxone_image.png'>
<ul>
<li>Inštalacija in zagon <a style="color: rgb(66, 175, 56); text-decoration: none;" href="https://www.loxone.com/int/">Loxone</a> sistema za pametno hišo</li>
<li>Montaža in zagon domofonskih sistemov</li>
<li>Odpiranje vrat na pin kodo ali kartico</li>
</ul>
</Service>
<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>
<li>Urejanje komunikacijskih vozlišč</li>
<li>Montaža in konfiguracija WiFi dostopnih točk</li>
</ul>
</Service>
<Service service='Elektroinštalacije' serviceImage='/electroinstalations_image.png'>
<ul>
<li>Napeljava električnih inštalacij</li>
<li>Zamenjava dotrajanih inštalacij</li>
<li>Zamenjava rezdelilnih omaric, varovalk, vtičnic in stikal</li>
<li>Montaža luči</li>
</ul>
</Service>
</div>

76
src/lib/Service.svelte Normal file
View File

@ -0,0 +1,76 @@
<script>
export let service = 'Lorem Ipsum';
export let serviceImage = '/default_image.jpg';
export let serviceImageAlt = 'Image';
export let imageOnRight = false;
</script>
<style>
.service{
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.service-content{
width: 100%;
display: flex;
flex-direction: column-reverse;
gap: 2em;
}
.image-right{
flex-direction: column-reverse;
}
.image{
width: 100%;
}
.image>img{
width: 100%;
border-radius: 2em;
}
.service-text{
width: 100%;
font-size: 1.3em;
color: #bcc0c2;
}
.service-name{
text-transform: uppercase;
}
@media (min-width: 900px){
.service-content{
flex-direction: row;
}
.image-right{
flex-direction: row-reverse;
}
}
h2{
font-size: 2em;
}
</style>
<div class="service">
<div class="service-name">
<h2>
{service}
</h2>
</div>
<div class="service-content" class:image-right={imageOnRight}>
<div class="image">
<img src={serviceImage} alt={serviceImageAlt}>
</div>
<div class="service-text">
<slot />
</div>
</div>
</div>

View File

@ -1,90 +0,0 @@
<script lang="ts">
// Cross-fading auto-advancing slideshow. Fills its parent (which sets the size).
let {
images = [],
interval = 4000,
alt = '',
fit = 'cover',
hoverZoom = false
}: {
images: string[];
interval?: number;
alt?: string;
fit?: 'cover' | 'contain';
hoverZoom?: boolean;
} = $props();
let current = $state(0);
$effect(() => {
if (images.length <= 1) return;
const id = setInterval(() => {
current = (current + 1) % images.length;
}, interval);
return () => clearInterval(id);
});
</script>
{#if images.length > 0}
<div class="slideshow" class:zoom={hoverZoom}>
{#each images as src, i (src)}
<img {src} {alt} class:active={i === current % images.length} style:object-fit={fit} loading="lazy" />
{/each}
{#if images.length > 1}
<div class="dots">
{#each images as _, i (i)}
<span class="dot" class:on={i === current % images.length}></span>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.slideshow {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.slideshow img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.8s ease, transform 0.35s ease;
}
.slideshow img.active {
opacity: 1;
}
.slideshow.zoom:hover img {
transform: scale(1.1);
}
.dots {
position: absolute;
bottom: 0.8em;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 0.5em;
z-index: 1;
}
.dot {
width: 0.55em;
height: 0.55em;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
transition: background-color 0.3s ease;
}
.dot.on {
background: #ffffff;
}
</style>

View File

@ -1,21 +0,0 @@
<script>
export let image_src = '';
export let width = '1.5em';
export let height = '1.5em';
export let icon_color = '#bcc0c2';
</script>
<style>
div {
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
</style>
<div style='
mask-image: url("{image_src}");
width: {width};
height: {height};
background-color: {icon_color};'>
</div>

Binary file not shown.

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 123 123" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M122.88,30.72l-0,61.44c-0,16.955 -13.765,30.72 -30.72,30.72l-61.44,-0c-16.955,-0 -30.72,-13.765 -30.72,-30.72l0,-61.44c0,-16.955 13.765,-30.72 30.72,-30.72l61.44,0c16.955,0 30.72,13.765 30.72,30.72Zm-10.874,0c0,-10.953 -8.893,-19.846 -19.846,-19.846l-61.44,-0c-10.953,-0 -19.846,8.893 -19.846,19.846l-0,61.44c-0,10.953 8.893,19.846 19.846,19.846l61.44,0c10.953,0 19.846,-8.893 19.846,-19.846l0,-61.44Z" style="fill:#bcc0c2;"/><circle cx="35.498" cy="35.498" r="13.42" style="fill:#c1c1c1;"/><path d="M35.498,22.078c7.406,-0 13.42,6.013 13.42,13.42c-0,7.406 -6.014,13.42 -13.42,13.42c-7.407,-0 -13.42,-6.014 -13.42,-13.42c-0,-7.407 6.013,-13.42 13.42,-13.42Zm-0,10.873c-1.406,0 -2.547,1.141 -2.547,2.547c0,1.405 1.141,2.546 2.547,2.546c1.405,0 2.546,-1.141 2.546,-2.546c0,-1.406 -1.141,-2.547 -2.546,-2.547Z" style="fill:#bcc0c2;"/><path d="M14.755,109.842c-0,-0 9.178,-34.977 25.798,-35.225c16.62,-0.248 10.419,13.395 25.054,13.395c14.636,0 13.1,-45.812 26.295,-45.891c13.515,-0.081 28.651,28.651 28.651,28.651l-5.829,33.612l-21.83,14.636l-62.511,-1.736l-15.628,-7.442Z" style="fill:#bcc0c2;"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1920 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="logo-logo"><g id="g17"><g id="path9"><g id="g11"><path id="path91" serif:id="path9" d="M322.186,258.939l0,-92.11l-65.718,-65.718l49.779,-49.778l115.496,115.496l0,143.587l-179.516,-0l0,-51.477l79.959,-0Zm-83.356,-63.628l35.276,0l0,35.276l-35.276,0l0,-35.276Z" style="fill:#34b2f9;"/></g></g><g id="path13"><g id="g15"><path id="path131" serif:id="path13" d="M190.75,166.96l0,143.586l-57.226,0l47.558,150.12l-181.084,-201.727l91.195,-0l0,-92.11l115.497,-115.496l49.778,49.778l-65.718,65.849Zm-67.416,28.351l35.276,0l-0,35.276l-35.276,0l-0,-35.276Z" style="fill:#57e64a;"/></g></g></g></g><g id="path367"><g id="text-logo"><g id="g23"><g id="text-logo-path-0"><path id="path3671" serif:id="path367" d="M636.049,310.417l-31.508,-0l-0,-146.737l33.983,-0l38.26,72.918l14.404,32.408l0.9,0c-0.599,-7.8 -1.427,-16.505 -2.476,-26.106c-1.048,-9.601 -1.575,-18.757 -1.575,-27.457l-0,-51.763l31.508,-0l0,146.737l-33.984,-0l-38.259,-73.144l-14.404,-31.958l-0.9,0c0.751,8.102 1.62,16.803 2.61,26.107c0.959,9.304 1.441,18.306 1.441,27.007l-0,51.988Zm222.807,-0l-33.309,-0l0,-118.83l-40.285,-0l-0,-27.907l113.879,-0l-0,27.907l-40.285,-0l-0,118.83Zm200.076,-0l-94.074,-0l-0,-146.737l91.823,-0l0,27.907l-58.74,-0l0,29.482l49.963,0l-0,27.682l-49.963,0l0,33.759l60.991,-0l-0,27.907Zm163.166,-0l-91.148,-0l0,-146.737l33.084,-0l-0,118.83l58.064,-0l0,27.907Zm101.276,-0l-33.083,-0l-0,-146.737l33.083,-0l0,146.737Zm322.733,2.701c-19.805,-0 -35.861,-6.797 -48.163,-20.391c-12.301,-13.561 -18.454,-32.349 -18.454,-56.354c-0,-24.005 6.153,-42.581 18.454,-55.724c12.302,-13.112 28.358,-19.67 48.163,-19.67c19.805,-0 35.86,6.603 48.162,19.805c12.302,13.202 18.455,31.733 18.455,55.589c-0,24.005 -6.153,42.793 -18.455,56.354c-12.302,13.594 -28.357,20.391 -48.162,20.391Zm-0,-28.583c10.051,0 18.004,-4.352 23.856,-13.053c5.851,-8.701 8.777,-20.404 8.777,-35.109c0,-14.552 -2.926,-26.003 -8.777,-34.344c-5.852,-8.313 -13.805,-12.468 -23.856,-12.468c-10.051,0 -18.005,4.155 -23.856,12.468c-5.852,8.341 -8.778,19.792 -8.778,34.344c0,14.705 2.926,26.408 8.778,35.109c5.851,8.701 13.805,13.053 23.856,13.053Zm166.767,25.882l-29.707,-0l-0,-146.737l36.234,-0l23.631,65.266c1.499,4.2 2.926,8.629 4.276,13.279c1.351,4.649 2.777,9.227 4.276,13.728l0.9,0c1.499,-4.501 2.895,-9.079 4.187,-13.728c1.26,-4.65 2.642,-9.079 4.141,-13.279l23.181,-65.266l36.009,-0l-0,146.737l-30.158,-0l0,-53.789c0,-7.202 0.527,-15.574 1.575,-25.116c1.049,-9.511 1.949,-17.793 2.701,-24.847l-0.9,0l-11.928,34.884l-20.93,56.04l-18.23,-0l-20.93,-56.04l-11.703,-34.884l-0.901,0c0.752,7.054 1.652,15.336 2.701,24.847c1.049,9.542 1.575,17.914 1.575,25.116l0,53.789Z" style="fill:#34b2f9;fill-rule:nonzero;"/></g></g></g></g><g id="path497"><path d="M527.122,310.42l-33.082,-0l-0,-146.738l33.082,0l0,146.738Zm916.886,-0l-43.21,-0l-0,-146.738l41.41,0c22.356,0 40.21,5.806 53.563,17.419c13.354,11.642 20.03,30.067 20.03,55.273c0,25.207 -6.601,43.843 -19.804,55.906c-13.203,12.092 -30.533,18.14 -51.989,18.14Zm-10.128,-119.957l-0,93.174l6.301,0c12.454,0 22.507,-3.495 30.159,-10.487c7.651,-6.963 11.478,-19.22 11.478,-36.776c-0,-17.553 -3.827,-29.601 -11.478,-36.144c-7.652,-6.511 -17.705,-9.767 -30.159,-9.767l-6.301,-0Z" style="fill:#57e64a;fill-rule:nonzero;"/></g><g transform="matrix(157.127,0,0,157.127,725.831,460.666)"></g><text x="210.871px" y="460.666px" style="font-family:'Arial-BoldMT', 'Arial', sans-serif;font-weight:700;font-size:157.127px;fill:#c43636;">ADMIN</text></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

7
src/lib/assets/mail.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(6.23174,0,0,-6.22835,-179.599,2542.96)">
<path d="M44.023,392.574C40.848,392.574 37.906,391.609 35.473,389.961L69.902,370.082L104.328,389.961C101.895,391.613 98.953,392.574 95.781,392.574L44.023,392.574ZM109.586,383.754L71.93,362.012C71.309,361.652 70.629,361.48 69.957,361.473L69.844,361.473C69.172,361.48 68.492,361.652 67.871,362.012L30.215,383.754C29.32,381.816 28.82,379.656 28.82,377.375L28.82,357C28.82,348.578 35.602,341.797 44.023,341.797L95.781,341.797C104.199,341.797 110.98,348.578 110.98,357L110.98,377.375C110.98,379.656 110.48,381.816 109.586,383.754Z" style="fill:white;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,65 +0,0 @@
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

@ -1,277 +0,0 @@
import { and, asc, desc, eq, ne } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import { db } from './db';
import {
bannerImages,
galleryImages,
newsPosts,
serviceImages,
services,
settings,
type ContactInfo,
type NewsPost
} from './db/schema';
const nextSort = (rows: { sortOrder: number }[]) =>
rows.reduce((max, r) => Math.max(max, r.sortOrder), -1) + 1;
/** Rewrite sortOrder to match the given id order (0..n-1). */
function applyOrder(ids: string[], table: typeof bannerImages | typeof services) {
ids.forEach((id, i) => {
db.update(table).set({ sortOrder: i }).where(eq(table.id, id)).run();
});
}
// ---------- Banner ----------
export function listBannerImages() {
return db
.select()
.from(bannerImages)
.orderBy(asc(bannerImages.sortOrder), asc(bannerImages.createdAt))
.all();
}
export function addBannerImage(imagePath: string) {
const sortOrder = nextSort(listBannerImages());
db.insert(bannerImages).values({ id: randomUUID(), imagePath, sortOrder }).run();
}
export function deleteBannerImage(id: string): string | null {
const row = db.select().from(bannerImages).where(eq(bannerImages.id, id)).get();
if (!row) return null;
db.delete(bannerImages).where(eq(bannerImages.id, id)).run();
return row.imagePath;
}
/** Gallery images (used by the /gallery page and the homepage preview grid). */
export function listGalleryImages(limit?: number) {
const base = db
.select()
.from(galleryImages)
.orderBy(asc(galleryImages.sortOrder), desc(galleryImages.createdAt));
return (limit ? base.limit(limit) : base).all();
}
export function moveBanner(id: string, dir: 'up' | 'down') {
const rows = listBannerImages();
const idx = rows.findIndex((r) => r.id === id);
const swap = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swap < 0 || swap >= rows.length) return;
const ids = rows.map((r) => r.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
applyOrder(ids, bannerImages);
}
// ---------- Services ----------
export function listServices() {
const rows = db
.select()
.from(services)
.orderBy(asc(services.sortOrder), asc(services.createdAt))
.all();
return rows.map((s) => ({ ...s, images: listServiceImages(s.id) }));
}
export function getService(id: string) {
const s = db.select().from(services).where(eq(services.id, id)).get();
if (!s) return null;
return { ...s, images: listServiceImages(id) };
}
export function listServiceImages(serviceId: string) {
return db
.select()
.from(serviceImages)
.where(eq(serviceImages.serviceId, serviceId))
.orderBy(asc(serviceImages.sortOrder))
.all();
}
export function createService(input: {
title: string;
description: string | null;
bullets: string | null;
}): string {
const id = randomUUID();
const sortOrder = nextSort(
db.select({ sortOrder: services.sortOrder }).from(services).all()
);
db.insert(services).values({ id, sortOrder, ...input }).run();
return id;
}
export function updateService(
id: string,
input: { title: string; description: string | null; bullets: string | null }
) {
db.update(services).set(input).where(eq(services.id, id)).run();
}
export function deleteService(id: string): string[] {
const imgs = listServiceImages(id).map((i) => i.imagePath);
db.delete(services).where(eq(services.id, id)).run(); // cascades to service_images rows
return imgs;
}
export function moveService(id: string, dir: 'up' | 'down') {
const rows = db.select().from(services).orderBy(asc(services.sortOrder), asc(services.createdAt)).all();
const idx = rows.findIndex((r) => r.id === id);
const swap = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swap < 0 || swap >= rows.length) return;
const ids = rows.map((r) => r.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
applyOrder(ids, services);
}
export function addServiceImage(serviceId: string, imagePath: string) {
const sortOrder = nextSort(listServiceImages(serviceId));
db.insert(serviceImages).values({ id: randomUUID(), serviceId, imagePath, sortOrder }).run();
}
export function deleteServiceImage(id: string): string | null {
const row = db.select().from(serviceImages).where(eq(serviceImages.id, id)).get();
if (!row) return null;
db.delete(serviceImages).where(eq(serviceImages.id, id)).run();
return row.imagePath;
}
// ---------- News ----------
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/č/g, 'c')
.replace(/š/g, 's')
.replace(/ž/g, 'z')
.replace(/đ/g, 'd')
.replace(/ć/g, 'c')
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function uniqueSlug(base: string, excludeId?: string): string {
const root = slugify(base) || 'objava';
let slug = root;
let n = 1;
while (true) {
const clash = db
.select({ id: newsPosts.id })
.from(newsPosts)
.where(excludeId ? and(eq(newsPosts.slug, slug), ne(newsPosts.id, excludeId)) : eq(newsPosts.slug, slug))
.get();
if (!clash) return slug;
n += 1;
slug = `${root}-${n}`;
}
}
export function listPosts(publishedOnly: boolean): NewsPost[] {
const rows = db.select().from(newsPosts).all();
const visible = publishedOnly ? rows.filter((p) => p.published) : rows;
return visible.sort((a, b) => {
const at = (a.publishedAt ?? a.createdAt).getTime();
const bt = (b.publishedAt ?? b.createdAt).getTime();
return bt - at;
});
}
export function getPost(id: string) {
return db.select().from(newsPosts).where(eq(newsPosts.id, id)).get();
}
export function getPublishedPostBySlug(slug: string) {
const p = db.select().from(newsPosts).where(eq(newsPosts.slug, slug)).get();
return p && p.published ? p : null;
}
export function createPost(input: {
title: string;
excerpt: string | null;
body: string;
coverImagePath: string | null;
published: boolean;
}): string {
const id = randomUUID();
const now = new Date();
db.insert(newsPosts)
.values({
id,
title: input.title,
slug: uniqueSlug(input.title),
excerpt: input.excerpt,
body: input.body,
coverImagePath: input.coverImagePath,
published: input.published,
publishedAt: input.published ? now : null,
createdAt: now,
updatedAt: now
})
.run();
return id;
}
export function updatePost(
id: string,
input: {
title: string;
excerpt: string | null;
body: string;
coverImagePath?: string | null;
published: boolean;
}
) {
const existing = getPost(id);
if (!existing) return;
db.update(newsPosts)
.set({
title: input.title,
slug: uniqueSlug(input.title, id),
excerpt: input.excerpt,
body: input.body,
...(input.coverImagePath !== undefined ? { coverImagePath: input.coverImagePath } : {}),
published: input.published,
// Set publishedAt the first time it becomes published.
publishedAt: input.published ? (existing.publishedAt ?? new Date()) : null,
updatedAt: new Date()
})
.where(eq(newsPosts.id, id))
.run();
}
export function deletePost(id: string): string | null {
const row = getPost(id);
if (!row) return null;
db.delete(newsPosts).where(eq(newsPosts.id, id)).run();
return row.coverImagePath;
}
// ---------- Settings / contact ----------
const DEFAULT_CONTACT: ContactInfo = {
email: 'info@intelidom.si',
phone: '040 77 11 66',
address: '',
showMap: false
};
export function getContact(): ContactInfo {
const row = db.select().from(settings).where(eq(settings.key, 'contact')).get();
if (!row) return DEFAULT_CONTACT;
try {
return { ...DEFAULT_CONTACT, ...(JSON.parse(row.value) as Partial<ContactInfo>) };
} catch {
return DEFAULT_CONTACT;
}
}
export function setContact(info: ContactInfo) {
const value = JSON.stringify(info);
db.insert(settings)
.values({ key: 'contact', value })
.onConflictDoUpdate({ target: settings.key, set: { value } })
.run();
}

View File

@ -1,19 +0,0 @@
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

@ -1,112 +0,0 @@
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())
});
/** Hero banner slideshow images shown at the top of the homepage. */
export const bannerImages = sqliteTable('banner_images', {
id: text('id').primaryKey(),
imagePath: text('image_path').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});
/** Services shown on the homepage (name + description + bullet list + images). */
export const services = sqliteTable('services', {
id: text('id').primaryKey(),
title: text('title').notNull(),
description: text('description'),
// One bullet per line; rendered as a list.
bullets: text('bullets'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});
/** Images belonging to a service; multiple rotate as a slideshow. */
export const serviceImages = sqliteTable('service_images', {
id: text('id').primaryKey(),
serviceId: text('service_id')
.notNull()
.references(() => services.id, { onDelete: 'cascade' }),
imagePath: text('image_path').notNull(),
sortOrder: integer('sort_order').notNull().default(0)
});
/** Blog / news posts. */
export const newsPosts = sqliteTable('news_posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
body: text('body').notNull().default(''),
coverImagePath: text('cover_image_path'),
published: integer('published', { mode: 'boolean' }).notNull().default(false),
publishedAt: integer('published_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});
/** Simple key/value store for site settings (contact info lives under key "contact"). */
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull()
});
export type User = typeof users.$inferSelect;
export type Session = typeof sessions.$inferSelect;
export type GalleryImage = typeof galleryImages.$inferSelect;
export type BannerImage = typeof bannerImages.$inferSelect;
export type Service = typeof services.$inferSelect;
export type ServiceImage = typeof serviceImages.$inferSelect;
export type NewsPost = typeof newsPosts.$inferSelect;
/** User shape safe to expose to the browser (no password hash). */
export type PublicUser = Pick<User, 'id' | 'email' | 'name' | 'role'>;
/** Contact settings stored (as JSON) under the "contact" settings key. */
export type ContactInfo = {
email: string;
phone: string;
address: string;
showMap: boolean;
};

View File

@ -1,40 +0,0 @@
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,62 +0,0 @@
import { count, eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import { db } from './db';
import { users, type PublicUser } from './db/schema';
import { hashPassword } from './auth';
export type Role = 'admin' | 'editor';
export function parseRole(value: FormDataEntryValue | null): Role | null {
return value === 'admin' || value === 'editor' ? value : null;
}
export function listUsers(): PublicUser[] {
return db
.select({ id: users.id, email: users.email, name: users.name, role: users.role })
.from(users)
.orderBy(users.createdAt)
.all();
}
export function getUser(id: string) {
return db.select().from(users).where(eq(users.id, id)).get();
}
export function getUserByEmail(email: string) {
return db.select().from(users).where(eq(users.email, email.toLowerCase())).get();
}
export function countAdmins(): number {
return db.select({ c: count() }).from(users).where(eq(users.role, 'admin')).get()?.c ?? 0;
}
export async function createUser(input: {
email: string;
name: string;
password: string;
role: Role;
}): Promise<void> {
const passwordHash = await hashPassword(input.password);
db.insert(users)
.values({
id: randomUUID(),
email: input.email.toLowerCase(),
name: input.name,
role: input.role,
passwordHash
})
.run();
}
export function updateUserRole(id: string, role: Role): void {
db.update(users).set({ role }).where(eq(users.id, id)).run();
}
export async function setUserPassword(id: string, password: string): Promise<void> {
const passwordHash = await hashPassword(password);
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
}
export function deleteUser(id: string): void {
db.delete(users).where(eq(users.id, id)).run();
}

View File

@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
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 Footer from '$lib/Footer.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'
let { children } = $props(); let { children } = $props();
// The admin dashboard provides its own chrome, so hide the public site
// header/footer on every /admin route (including the login page).
const isAdmin = $derived(page.url.pathname.startsWith('/admin'));
</script> </script>
<svelte:head> <svelte:head>
@ -19,14 +20,8 @@
<link rel="icon" href={intelidom_icon} /> <link rel="icon" href={intelidom_icon} />
</svelte:head> </svelte:head>
{#if !isAdmin} <Header></Header>
<Header></Header> <Main></Main>
{/if} <Footer></Footer>
<div class="app-content"> {@render children?.()}
{@render children?.()}
</div>
{#if !isAdmin}
<Footer></Footer>
{/if}

View File

@ -1,38 +0,0 @@
import type { PageServerLoad } from './$types';
import {
getContact,
listBannerImages,
listGalleryImages,
listPosts,
listServices
} from '$lib/server/content';
export const load: PageServerLoad = () => {
const banner = listBannerImages().map((i) => `/uploads/${i.imagePath}`);
// Up to 12 (4x3) images for the homepage gallery preview grid.
const gallery = listGalleryImages(12).map((i) => `/uploads/${i.imagePath}`);
const services = listServices().map((s) => ({
id: s.id,
title: s.title,
description: s.description,
bullets: (s.bullets ?? '')
.split('\n')
.map((b) => b.trim())
.filter(Boolean),
images: s.images.map((im) => `/uploads/${im.imagePath}`)
}));
const posts = listPosts(true)
.slice(0, 3)
.map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
coverUrl: p.coverImagePath ? `/uploads/${p.coverImagePath}` : null,
date: (p.publishedAt ?? p.createdAt).toISOString()
}));
return { banner, services, gallery, posts, contact: getContact() };
};

View File

@ -1,352 +0,0 @@
<script lang="ts">
import Slideshow from '$lib/Slideshow.svelte';
import SvgImage from '$lib/SvgImage.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString('sl-SI');
const mapSrc = (address: string) =>
`https://www.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
</script>
{#if data.banner.length > 0}
<section class="hero">
<Slideshow images={data.banner} alt="InteliDom" interval={5000} />
</section>
{/if}
<div class="page">
{#if data.services.length > 0}
<section class="services" id="storitve">
{#each data.services as service, i (service.id)}
<article class="service">
<h2 class="service-title">{service.title}</h2>
<div class="service-content" class:reverse={i % 2 === 1}>
{#if service.images.length > 0}
<div class="service-media">
<Slideshow images={service.images} alt={service.title} hoverZoom />
</div>
{/if}
<div class="service-text">
{#if service.description}<p class="desc">{service.description}</p>{/if}
{#if service.bullets.length > 0}
<ul>
{#each service.bullets as bullet}<li>{bullet}</li>{/each}
</ul>
{/if}
</div>
</div>
</article>
{/each}
</section>
{/if}
{#if data.gallery.length > 0}
<section class="gallery-preview" id="galerija">
<h2 class="section-title">Galerija</h2>
<a class="gallery-link" href="/gallery" aria-label="Odpri galerijo">
<div class="gallery-grid">
{#each data.gallery as url (url)}
<div class="g-tile"><img src={url} alt="" loading="lazy" /></div>
{/each}
</div>
</a>
</section>
{/if}
{#if data.posts.length > 0}
<section class="news" id="novice">
<h2 class="section-title">Novice</h2>
<div class="news-grid">
{#each data.posts as post (post.slug)}
<a class="post-card" href="/news/{post.slug}">
{#if post.coverUrl}
<div class="post-cover"><img src={post.coverUrl} alt="" loading="lazy" /></div>
{/if}
<div class="post-body">
<span class="post-date">{fmtDate(post.date)}</span>
<h3>{post.title}</h3>
{#if post.excerpt}<p>{post.excerpt}</p>{/if}
</div>
</a>
{/each}
</div>
<a class="more" href="/news">Vse novice →</a>
</section>
{/if}
<section class="contact" id="kontakt">
<h2 class="section-title">Kontakt</h2>
<div class="contact-card">
<div class="contact-methods">
{#if data.contact.email}
<div class="contact-line">
<SvgImage image_src="/mail.svg" icon_color="#42af38" width="1.4em" height="1.4em" />
<a href="mailto:{data.contact.email}">{data.contact.email}</a>
</div>
{/if}
{#if data.contact.phone}
<div class="contact-line">
<SvgImage image_src="/phone.svg" icon_color="#42af38" width="1.4em" height="1.4em" />
<a href="tel:{data.contact.phone.replace(/\s+/g, '')}">{data.contact.phone}</a>
</div>
{/if}
</div>
{#if data.contact.showMap && data.contact.address}
<div class="map">
<iframe title="Zemljevid" src={mapSrc(data.contact.address)} loading="lazy"></iframe>
</div>
{/if}
{#if data.contact.address}
<p class="address">{data.contact.address}</p>
{/if}
</div>
</section>
</div>
<style>
.hero {
width: 100%;
height: clamp(220px, 42vw, 460px);
background: #11161a;
}
.page {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 1em;
display: flex;
flex-direction: column;
gap: 3em;
box-sizing: border-box;
}
.services {
display: flex;
flex-direction: column;
gap: 3.5em;
}
.service {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5em;
}
.service-title {
text-transform: uppercase;
text-align: center;
font-size: clamp(1.5em, 4vw, 2em);
margin: 0;
}
.service-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2em;
}
.service-media {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 2em;
overflow: hidden;
background: #11161a;
}
.service-text {
width: 100%;
font-size: 1.3em;
color: #bcc0c2;
}
.desc {
margin: 0 0 0.8em;
}
.service-text ul {
margin: 0;
padding-left: 1.2em;
}
.service-text li {
margin-bottom: 0.5em;
}
.section-title {
text-align: center;
margin: 0 0 1em;
font-size: clamp(2em, 5vw, 2.8em);
text-transform: uppercase;
}
.gallery-link {
display: block;
text-decoration: none;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.6em;
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 55%, transparent 100%);
}
.g-tile {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: #11161a;
}
.g-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.g-tile:hover img {
transform: scale(1.05);
}
.news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.2em;
}
.post-card {
background: #11161a;
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
transition: transform 0.2s ease;
}
.post-card:hover {
transform: translateY(-3px);
}
.post-cover {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.post-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-body {
padding: 1em;
display: flex;
flex-direction: column;
gap: 0.4em;
}
.post-date {
color: #9aa0a6;
font-size: 0.8em;
}
.post-body h3 {
margin: 0;
font-size: 1.1em;
}
.post-body p {
margin: 0;
color: #c7c9cf;
font-size: 0.92em;
}
.more {
display: inline-block;
margin-top: 1.2em;
color: #42af38;
text-decoration: none;
}
.contact-card {
background: #11161a;
border-radius: 1.5em;
padding: clamp(1.5em, 4vw, 2.5em);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5em;
text-align: center;
font-size: 1.1em;
}
.contact-methods {
display: flex;
flex-wrap: wrap;
gap: 2em;
justify-content: center;
}
.contact-line {
display: flex;
align-items: center;
gap: 0.7em;
}
.contact-card a {
color: #e5e4ea;
text-decoration: none;
}
.contact-card a:hover {
color: #42af38;
}
.address {
white-space: pre-line;
color: #9aa0a6;
margin: 0;
font-size: 0.95em;
}
.map {
width: 100%;
max-width: 640px;
aspect-ratio: 16 / 9;
border-radius: 12px;
overflow: hidden;
}
.map iframe {
width: 100%;
height: 100%;
border: 0;
}
@media (min-width: 800px) {
.service-content {
flex-direction: row;
align-items: center;
}
.service-content.reverse {
flex-direction: row-reverse;
}
.service-media {
width: 50%;
flex-shrink: 0;
}
}
</style>

View File

@ -1,18 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
// hooks.server.ts already blocks unauthenticated access; this also narrows the type.
if (!locals.user) {
throw redirect(303, '/admin/login');
}
return {
user: {
id: locals.user.id,
email: locals.user.email,
name: locals.user.name,
role: locals.user.role
}
};
};

View File

@ -1,198 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import intelidom_admin_banner from '$lib/assets/intelidom_admin_banner.svg';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = [
{ href: '/admin/gallery', label: 'Galerija' },
{ href: '/admin/banner', label: 'Pasica' },
{ href: '/admin/services', label: 'Storitve' },
{ href: '/admin/news', label: 'Novice' },
{ href: '/admin/contact', label: 'Kontakt' }
];
const isAdmin = $derived(data.user.role === 'admin');
</script>
<svelte:head>
<title>Admin — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="dash">
<aside class="sidebar">
<div class="brand">
<a href="/"><img src={intelidom_admin_banner} alt="InteliDom" /></a>
</div>
<nav>
{#each tabs as tab (tab.href)}
<a href={tab.href} class="tab" class:active={page.url.pathname.startsWith(tab.href)}>
{tab.label}
</a>
{/each}
{#if isAdmin}
<a
href="/admin/users"
class="tab"
class:active={page.url.pathname.startsWith('/admin/users')}
>
Uporabniki
</a>
{/if}
</nav>
<div class="account">
<div class="who">
<span class="who-name">{data.user.name}</span>
<span class="who-role">{data.user.role}</span>
</div>
<form method="POST" action="/admin/logout">
<button type="submit" class="logout">Odjava</button>
</form>
</div>
</aside>
<main class="dash-main">
{@render children()}
</main>
</div>
<style>
.dash {
width: 100%;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sidebar {
background: #11161a;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em;
}
.brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25em;
}
.brand a {
display: block;
line-height: 0;
}
.brand img {
width: 100%;
max-width: 150px;
display: block;
}
nav {
display: flex;
flex-direction: row;
gap: 0.3em;
overflow-x: auto;
}
.tab {
color: #c7c9cf;
text-decoration: none;
padding: 0.5em 0.9em;
border-radius: 8px;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
}
.tab:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.tab.active {
background: rgba(66, 175, 56, 0.15);
color: #fff;
}
.account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
}
.who {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.who-name {
color: #e5e4ea;
font-weight: 600;
}
.who-role {
color: #9aa0a6;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.logout {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border: none;
border-radius: 8px;
padding: 0.5em 1em;
font-family: inherit;
font-size: 0.9em;
cursor: pointer;
}
.logout:hover {
background: rgba(255, 255, 255, 0.05);
}
.dash-main {
flex: 1 1 auto;
min-width: 0; /* allow the flex item to shrink so wide content wraps instead of overflowing */
box-sizing: border-box;
padding: 1.5em 1.5em 3em;
}
@media (min-width: 900px) {
.dash {
flex-direction: row;
}
.sidebar {
width: 220px;
flex-shrink: 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
nav {
flex-direction: column;
flex: 1;
overflow-x: visible;
}
.account {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@ -1,7 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// The dashboard landing page: send /admin to the first tab.
export const load: PageServerLoad = () => {
throw redirect(307, '/admin/gallery');
};

View File

@ -1 +0,0 @@
<!-- /admin redirects to /admin/gallery in +page.server.ts; this never renders. -->

View File

@ -1,35 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { saveImage, deleteUpload } from '$lib/server/storage';
import { addBannerImage, deleteBannerImage, listBannerImages, moveBanner } from '$lib/server/content';
export const load: PageServerLoad = () => ({
images: listBannerImages().map((i) => ({ id: i.id, url: `/uploads/${i.imagePath}` }))
});
export const actions: Actions = {
upload: async ({ request }) => {
const form = await request.formData();
const files = form
.getAll('images')
.filter((e): e is File => e instanceof File && e.size > 0);
if (files.length === 0) return fail(400, { error: 'Izberite vsaj eno sliko.' });
for (const file of files) {
addBannerImage(await saveImage('banner', file));
}
return { success: `Naloženih slik: ${files.length}.` };
},
delete: async ({ request }) => {
const form = await request.formData();
const path = deleteBannerImage(String(form.get('id') ?? ''));
if (path) deleteUpload(path);
return { success: 'Slika izbrisana.' };
},
move: async ({ request }) => {
const form = await request.formData();
moveBanner(String(form.get('id') ?? ''), form.get('dir') === 'up' ? 'up' : 'down');
return { success: null };
}
};

View File

@ -1,121 +0,0 @@
<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>
<section class="admin-section">
<h1>Pasica</h1>
<p class="admin-notice">
Slike v pasici se prikažejo kot diaprojekcija na vrhu spletne strani. Če ni nobene slike,
se pasica ne prikaže. Vrstni red spodaj določa vrstni red prikaza.
</p>
<form
class="admin-card"
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 pasico</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit" disabled={uploading}>
{uploading ? 'Nalaganje…' : 'Naloži'}
</button>
</form>
<h2>Slike v pasici</h2>
{#if data.images.length === 0}
<p class="admin-notice">Ni slik. Pasica se ne bo prikazala.</p>
{:else}
<div class="strip">
{#each data.images as image, i (image.id)}
<div class="banner-tile">
<img src={image.url} alt="" loading="lazy" />
<div class="tile-actions">
<form method="POST" action="?/move" use:enhance>
<input type="hidden" name="id" value={image.id} />
<input type="hidden" name="dir" value="up" />
<button type="submit" disabled={i === 0} aria-label="Premakni levo"></button>
</form>
<form method="POST" action="?/move" use:enhance>
<input type="hidden" name="id" value={image.id} />
<input type="hidden" name="dir" value="down" />
<button type="submit" disabled={i === data.images.length - 1} aria-label="Premakni desno"></button>
</form>
<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>
</div>
{/each}
</div>
{/if}
</section>
<style>
.strip {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
}
.banner-tile {
background: #11161a;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.banner-tile img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
.tile-actions {
display: flex;
align-items: center;
gap: 0.4em;
padding: 0.5em;
}
.tile-actions button {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border: none;
border-radius: 6px;
padding: 0.35em 0.6em;
font-family: inherit;
font-size: 0.85em;
cursor: pointer;
}
.tile-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.tile-actions .del {
margin-left: auto;
background: rgba(220, 40, 40, 0.9);
color: #fff;
}
</style>

View File

@ -1,17 +0,0 @@
import type { Actions, PageServerLoad } from './$types';
import { getContact, setContact } from '$lib/server/content';
export const load: PageServerLoad = () => ({ contact: getContact() });
export const actions: Actions = {
save: async ({ request }) => {
const form = await request.formData();
setContact({
email: String(form.get('email') ?? '').trim(),
phone: String(form.get('phone') ?? '').trim(),
address: String(form.get('address') ?? '').trim(),
showMap: form.get('showMap') === 'on'
});
return { success: 'Shranjeno.' };
}
};

View File

@ -1,44 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<section class="admin-section">
<h1>Kontakt</h1>
<form class="admin-card" method="POST" action="?/save" use:enhance>
<label>
E-naslov
<input class="admin-input" type="email" name="email" value={data.contact.email} />
</label>
<label>
Telefon
<input class="admin-input" type="text" name="phone" value={data.contact.phone} />
</label>
<label>
Naslov
<textarea class="admin-input" name="address" rows="3">{data.contact.address}</textarea>
</label>
<label class="inline">
<input type="checkbox" name="showMap" checked={data.contact.showMap} />
Prikaži zemljevid naslova na spletni strani
</label>
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit">Shrani</button>
</form>
</section>
<style>
textarea.admin-input {
resize: vertical;
font-family: inherit;
}
.inline {
flex-direction: row !important;
align-items: center;
gap: 0.5em !important;
}
</style>

View File

@ -1,60 +0,0 @@
import { fail } 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';
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.' };
}
};

View File

@ -1,106 +0,0 @@
<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>
<section class="admin-section">
<h1>Galerija</h1>
<form
class="admin-card"
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</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<label>
Opis (neobvezno)
<input class="admin-input" type="text" name="caption" placeholder="npr. Montaža omarice" />
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit" disabled={uploading}>
{uploading ? 'Nalaganje…' : 'Naloži'}
</button>
</form>
<h2>Slike v galeriji</h2>
{#if data.images.length === 0}
<p class="admin-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>
.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;
border: none;
padding: 0.3em 0.6em;
font-size: 0.8em;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
}
</style>

View File

@ -1,36 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { deleteUpload } from '$lib/server/storage';
import { createPost, deletePost, listPosts } from '$lib/server/content';
export const load: PageServerLoad = () => ({
posts: listPosts(false).map((p) => ({
id: p.id,
title: p.title,
published: p.published,
updatedAt: p.updatedAt.toISOString()
}))
});
export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
if (!title) return fail(400, { error: 'Vnesite naslov objave.' });
const id = createPost({
title,
excerpt: null,
body: '',
coverImagePath: null,
published: false
});
throw redirect(303, `/admin/news/${id}`);
},
delete: async ({ request }) => {
const form = await request.formData();
const cover = deletePost(String(form.get('id') ?? ''));
if (cover) deleteUpload(cover);
return { success: 'Objava izbrisana.' };
}
};

View File

@ -1,108 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const fmt = (iso: string) => new Date(iso).toLocaleDateString('sl-SI');
</script>
<section class="admin-section">
<h1>Novice</h1>
<form class="admin-card" method="POST" action="?/create" use:enhance>
<h2>Nova objava</h2>
<label>
Naslov
<input class="admin-input" type="text" name="title" required />
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
<button class="admin-btn" type="submit">Ustvari osnutek</button>
</form>
<h2>Objave</h2>
{#if data.posts.length === 0}
<p class="admin-notice">Ni objav.</p>
{:else}
<div class="list">
{#each data.posts as p (p.id)}
<div class="row">
<div class="meta">
<span class="title">{p.title}</span>
<span class="sub">Posodobljeno {fmt(p.updatedAt)}</span>
</div>
<span class="badge" class:on={p.published}>{p.published ? 'Objavljeno' : 'Osnutek'}</span>
<div class="row-actions">
<a class="btn-link" href="/admin/news/{p.id}">Uredi</a>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={p.id} />
<button class="admin-btn admin-btn-danger" type="submit">Izbriši</button>
</form>
</div>
</div>
{/each}
</div>
{/if}
</section>
<style>
.list {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.row {
background: #11161a;
border-radius: 10px;
padding: 0.7em 0.9em;
display: flex;
align-items: center;
gap: 0.9em;
flex-wrap: wrap;
}
.meta {
display: flex;
flex-direction: column;
flex: 1;
min-width: 120px;
}
.title {
font-weight: 600;
}
.sub {
color: #9aa0a6;
font-size: 0.82em;
}
.badge {
font-size: 0.78em;
padding: 0.25em 0.6em;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #9aa0a6;
}
.badge.on {
background: rgba(66, 175, 56, 0.18);
color: #7fd873;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.4em;
}
.btn-link {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border-radius: 6px;
padding: 0.4em 0.7em;
font-size: 0.9em;
text-decoration: none;
}
</style>

View File

@ -1,57 +0,0 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { saveImage, deleteUpload } from '$lib/server/storage';
import { deletePost, getPost, updatePost } from '$lib/server/content';
export const load: PageServerLoad = ({ params }) => {
const post = getPost(params.id);
if (!post) throw error(404, 'Objava ne obstaja.');
return {
post: {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
body: post.body,
published: post.published,
coverUrl: post.coverImagePath ? `/uploads/${post.coverImagePath}` : null
}
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
if (!title) return fail(400, { error: 'Vnesite naslov.' });
const existing = getPost(params.id);
if (!existing) throw error(404, 'Objava ne obstaja.');
let coverImagePath: string | null | undefined;
const coverFile = form.get('cover');
if (coverFile instanceof File && coverFile.size > 0) {
coverImagePath = await saveImage('news', coverFile);
if (existing.coverImagePath) deleteUpload(existing.coverImagePath);
} else if (form.get('removeCover') === 'on' && existing.coverImagePath) {
deleteUpload(existing.coverImagePath);
coverImagePath = null;
}
updatePost(params.id, {
title,
excerpt: String(form.get('excerpt') ?? '').trim() || null,
body: String(form.get('body') ?? ''),
published: form.get('published') === 'on',
...(coverImagePath !== undefined ? { coverImagePath } : {})
});
return { success: 'Shranjeno.' };
},
delete: async ({ request }) => {
const form = await request.formData();
const cover = deletePost(String(form.get('id') ?? ''));
if (cover) deleteUpload(cover);
throw redirect(303, '/admin/news');
}
};

View File

@ -1,134 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
</script>
<section class="admin-section">
<a class="back" href="/admin/news">← Nazaj na novice</a>
<div class="head">
<h1>Uredi objavo</h1>
{#if data.post.published}
<a class="view" href="/news/{data.post.slug}" target="_blank" rel="noreferrer">Ogled ↗</a>
{/if}
</div>
<form
class="admin-card"
method="POST"
action="?/update"
enctype="multipart/form-data"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update();
saving = false;
};
}}
>
<label>
Naslov
<input class="admin-input" type="text" name="title" value={data.post.title} required />
</label>
<label>
Povzetek (neobvezno)
<textarea class="admin-input" name="excerpt" rows="2">{data.post.excerpt ?? ''}</textarea>
</label>
<div class="cover">
<span class="lbl">Naslovna slika</span>
{#if data.post.coverUrl}
<img class="cover-preview" src={data.post.coverUrl} alt="" />
<label class="inline">
<input type="checkbox" name="removeCover" /> Odstrani naslovno sliko
</label>
{/if}
<input type="file" name="cover" accept="image/*" />
</div>
<label>
Vsebina
<textarea class="admin-input body" name="body" rows="14">{data.post.body}</textarea>
</label>
<label class="inline">
<input type="checkbox" name="published" checked={data.post.published} /> Objavljeno
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<div class="actions">
<button class="admin-btn" type="submit" disabled={saving}>
{saving ? 'Shranjevanje…' : 'Shrani'}
</button>
</div>
</form>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={data.post.id} />
<button class="admin-btn admin-btn-danger" type="submit">Izbriši objavo</button>
</form>
</section>
<style>
.back {
color: #9aa0a6;
text-decoration: none;
font-size: 0.9em;
}
.back:hover {
color: #e5e4ea;
}
.head {
display: flex;
align-items: baseline;
gap: 1em;
}
.view {
color: #42af38;
text-decoration: none;
font-size: 0.9em;
}
textarea.admin-input {
resize: vertical;
font-family: inherit;
}
textarea.body {
line-height: 1.5;
}
.cover {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.lbl {
font-size: 0.9em;
color: #c7c9cf;
}
.cover-preview {
max-width: 260px;
border-radius: 8px;
}
.inline {
flex-direction: row !important;
align-items: center;
gap: 0.5em !important;
}
.actions {
display: flex;
gap: 0.6em;
}
</style>

View File

@ -1,52 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { deleteUpload, saveImage } from '$lib/server/storage';
import {
addServiceImage,
createService,
deleteService,
listServices,
moveService
} from '$lib/server/content';
export const load: PageServerLoad = () => ({
services: listServices().map((s) => ({
id: s.id,
title: s.title,
imageCount: s.images.length,
thumb: s.images[0] ? `/uploads/${s.images[0].imagePath}` : null
}))
});
export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
if (!title) return fail(400, { error: 'Vnesite naziv storitve.' });
const id = createService({
title,
description: String(form.get('description') ?? '').trim() || null,
bullets: String(form.get('bullets') ?? '').trim() || null
});
const files = form
.getAll('images')
.filter((e): e is File => e instanceof File && e.size > 0);
for (const file of files) {
addServiceImage(id, await saveImage('services', file));
}
return { success: 'Storitev dodana.' };
},
delete: async ({ request }) => {
const form = await request.formData();
const paths = deleteService(String(form.get('id') ?? ''));
for (const p of paths) deleteUpload(p);
return { success: 'Storitev izbrisana.' };
},
move: async ({ request }) => {
const form = await request.formData();
moveService(String(form.get('id') ?? ''), form.get('dir') === 'up' ? 'up' : 'down');
return { success: null };
}
};

View File

@ -1,176 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let creating = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<section class="admin-section">
<h1>Storitve</h1>
<form
class="admin-card"
method="POST"
action="?/create"
enctype="multipart/form-data"
use:enhance={() => {
creating = true;
return async ({ update }) => {
await update({ reset: true });
creating = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Dodaj storitev</h2>
<label>
Naziv
<input class="admin-input" type="text" name="title" required />
</label>
<label>
Opis (neobvezno)
<textarea class="admin-input" name="description" rows="2"></textarea>
</label>
<label>
Postavke seznama (ena na vrstico, neobvezno)
<textarea class="admin-input" name="bullets" rows="3"></textarea>
</label>
<label>
Slike (neobvezno, lahko več)
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple />
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit" disabled={creating}>
{creating ? 'Dodajanje…' : 'Dodaj'}
</button>
</form>
<h2>Obstoječe storitve</h2>
{#if data.services.length === 0}
<p class="admin-notice">Ni storitev. Dodajte prvo zgoraj.</p>
{:else}
<div class="list">
{#each data.services as s, i (s.id)}
<div class="row">
<div class="thumb">
{#if s.thumb}<img src={s.thumb} alt="" />{:else}<span class="noimg"></span>{/if}
</div>
<div class="meta">
<span class="title">{s.title}</span>
<span class="sub">{s.imageCount} slik</span>
</div>
<div class="row-actions">
<form method="POST" action="?/move" use:enhance>
<input type="hidden" name="id" value={s.id} />
<input type="hidden" name="dir" value="up" />
<button type="submit" disabled={i === 0} aria-label="Gor"></button>
</form>
<form method="POST" action="?/move" use:enhance>
<input type="hidden" name="id" value={s.id} />
<input type="hidden" name="dir" value="down" />
<button type="submit" disabled={i === data.services.length - 1} aria-label="Dol"></button>
</form>
<a class="admin-btn admin-btn-ghost btn-link" href="/admin/services/{s.id}">Uredi</a>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={s.id} />
<button class="admin-btn admin-btn-danger" type="submit">Izbriši</button>
</form>
</div>
</div>
{/each}
</div>
{/if}
</section>
<style>
textarea.admin-input {
resize: vertical;
font-family: inherit;
}
.list {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.row {
background: #11161a;
border-radius: 10px;
padding: 0.7em 0.9em;
display: flex;
align-items: center;
gap: 0.9em;
flex-wrap: wrap;
}
.thumb {
width: 64px;
height: 48px;
border-radius: 6px;
overflow: hidden;
background: #0a0d0f;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.noimg {
color: #555;
}
.meta {
display: flex;
flex-direction: column;
flex: 1;
min-width: 100px;
}
.title {
font-weight: 600;
}
.sub {
color: #9aa0a6;
font-size: 0.82em;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.4em;
flex-wrap: wrap;
}
.row-actions button:not(.admin-btn),
.btn-link {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border: none;
border-radius: 6px;
padding: 0.4em 0.6em;
font-family: inherit;
font-size: 0.9em;
cursor: pointer;
}
.btn-link {
text-decoration: none;
}
.row-actions button:disabled {
opacity: 0.4;
cursor: default;
}
</style>

View File

@ -1,51 +0,0 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { saveImage, deleteUpload } from '$lib/server/storage';
import { addServiceImage, deleteServiceImage, getService, updateService } from '$lib/server/content';
export const load: PageServerLoad = ({ params }) => {
const service = getService(params.id);
if (!service) throw error(404, 'Storitev ne obstaja.');
return {
service: {
id: service.id,
title: service.title,
description: service.description,
bullets: service.bullets,
images: service.images.map((im) => ({ id: im.id, url: `/uploads/${im.imagePath}` }))
}
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
if (!title) return fail(400, { error: 'Vnesite naziv.' });
updateService(params.id, {
title,
description: String(form.get('description') ?? '').trim() || null,
bullets: String(form.get('bullets') ?? '').trim() || null
});
return { success: 'Shranjeno.' };
},
addImage: async ({ request, params }) => {
const form = await request.formData();
const files = form
.getAll('images')
.filter((e): e is File => e instanceof File && e.size > 0);
if (files.length === 0) return fail(400, { error: 'Izberite vsaj eno sliko.' });
for (const file of files) {
addServiceImage(params.id, await saveImage('services', file));
}
return { success: `Naloženih slik: ${files.length}.` };
},
deleteImage: async ({ request }) => {
const form = await request.formData();
const path = deleteServiceImage(String(form.get('imageId') ?? ''));
if (path) deleteUpload(path);
return { success: 'Slika izbrisana.' };
}
};

View File

@ -1,120 +0,0 @@
<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>
<section class="admin-section">
<a class="back" href="/admin/services">← Nazaj na storitve</a>
<h1>Uredi storitev</h1>
<form class="admin-card" method="POST" action="?/update" use:enhance>
<label>
Naziv
<input class="admin-input" type="text" name="title" value={data.service.title} required />
</label>
<label>
Opis (neobvezno)
<textarea class="admin-input" name="description" rows="2">{data.service.description ?? ''}</textarea>
</label>
<label>
Postavke seznama (ena na vrstico)
<textarea class="admin-input" name="bullets" rows="4">{data.service.bullets ?? ''}</textarea>
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit">Shrani</button>
</form>
<form
class="admin-card"
method="POST"
action="?/addImage"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Slike storitve</h2>
<p class="admin-notice">Če je slik več, se izmenjujejo kot diaprojekcija.</p>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<button class="admin-btn" type="submit" disabled={uploading}>
{uploading ? 'Nalaganje…' : 'Naloži'}
</button>
</form>
{#if data.service.images.length > 0}
<div class="grid">
{#each data.service.images as image (image.id)}
<div class="tile">
<img src={image.url} alt="" loading="lazy" />
<form method="POST" action="?/deleteImage" use:enhance>
<input type="hidden" name="imageId" value={image.id} />
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<style>
.back {
color: #9aa0a6;
text-decoration: none;
font-size: 0.9em;
}
.back:hover {
color: #e5e4ea;
}
textarea.admin-input {
resize: vertical;
font-family: inherit;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 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 .del {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 40, 40, 0.9);
color: #fff;
border: none;
padding: 0.3em 0.6em;
font-size: 0.8em;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
}
</style>

View File

@ -1,104 +0,0 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
countAdmins,
createUser,
deleteUser,
getUser,
getUserByEmail,
listUsers,
parseRole,
setUserPassword,
updateUserRole
} from '$lib/server/users';
function requireAdmin(locals: App.Locals) {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Samo administratorji lahko upravljajo uporabnike.');
}
return locals.user;
}
export const load: PageServerLoad = ({ locals }) => {
// Editors don't manage users — send them back to the gallery.
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(303, '/admin/gallery');
}
return { users: listUsers(), currentUserId: locals.user.id };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const email = String(form.get('email') ?? '')
.trim()
.toLowerCase();
const name = String(form.get('name') ?? '').trim();
const password = String(form.get('password') ?? '');
const role = parseRole(form.get('role'));
if (!email || !name || !password || !role) {
return fail(400, { error: 'Izpolnite vsa polja.' });
}
if (password.length < 8) {
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
}
if (getUserByEmail(email)) {
return fail(400, { error: 'Uporabnik s tem e-naslovom že obstaja.' });
}
await createUser({ email, name, password, role });
return { success: `Uporabnik ${email} dodan.` };
},
setRole: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const role = parseRole(form.get('role'));
if (!role) return fail(400, { error: 'Neveljavna vloga.' });
const target = getUser(id);
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
if (target.role === 'admin' && role === 'editor' && countAdmins() <= 1) {
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
}
updateUserRole(id, role);
return { success: 'Vloga posodobljena.' };
},
resetPassword: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const password = String(form.get('password') ?? '');
if (password.length < 8) {
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
}
if (!getUser(id)) return fail(404, { error: 'Uporabnik ne obstaja.' });
await setUserPassword(id, password);
return { success: 'Geslo posodobljeno.' };
},
delete: async ({ request, locals }) => {
const admin = requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (id === admin.id) {
return fail(400, { error: 'Ne morete izbrisati lastnega računa.' });
}
const target = getUser(id);
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
if (target.role === 'admin' && countAdmins() <= 1) {
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
}
deleteUser(id);
return { success: 'Uporabnik izbrisan.' };
}
};

View File

@ -1,186 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
// Which user's "reset password" box is open.
let resettingId: string | null = $state(null);
</script>
<section class="admin-section">
<h1>Uporabniki</h1>
<form class="admin-card" method="POST" action="?/create" use:enhance>
<h2>Dodaj uporabnika</h2>
<div class="fields">
<label>
Ime
<input class="admin-input" type="text" name="name" required />
</label>
<label>
E-naslov
<input class="admin-input" type="email" name="email" required />
</label>
<label>
Geslo
<input class="admin-input" type="password" name="password" minlength="8" required />
</label>
<label>
Vloga
<select class="admin-input" name="role">
<option value="editor">Urejevalec</option>
<option value="admin">Administrator</option>
</select>
</label>
</div>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit">Dodaj</button>
</form>
<h2>Obstoječi uporabniki</h2>
<div class="users">
{#each data.users as u (u.id)}
<div class="user-row">
<div class="info">
<span class="name">{u.name}{#if u.id === data.currentUserId}<span class="you"> (vi)</span>{/if}</span>
<span class="email">{u.email}</span>
</div>
<div class="controls">
<form method="POST" action="?/setRole" use:enhance class="role-form">
<input type="hidden" name="id" value={u.id} />
<select class="admin-input role-select" name="role" value={u.role}>
<option value="editor">Urejevalec</option>
<option value="admin">Administrator</option>
</select>
<button class="admin-btn" type="submit">Shrani</button>
</form>
<button
class="admin-btn admin-btn-ghost"
type="button"
onclick={() => (resettingId = resettingId === u.id ? null : u.id)}
>
Geslo
</button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={u.id} />
<button
class="admin-btn admin-btn-danger"
type="submit"
disabled={u.id === data.currentUserId}
>
Izbriši
</button>
</form>
</div>
{#if resettingId === u.id}
<form
method="POST"
action="?/resetPassword"
use:enhance={() => async ({ update }) => {
await update();
resettingId = null;
}}
class="reset-form"
>
<input type="hidden" name="id" value={u.id} />
<input
class="admin-input"
type="password"
name="password"
placeholder="Novo geslo (min. 8 znakov)"
minlength="8"
required
/>
<button class="admin-btn" type="submit">Nastavi geslo</button>
</form>
{/if}
</div>
{/each}
</div>
</section>
<style>
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9em;
}
.users {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.user-row {
background: #11161a;
border-radius: 10px;
padding: 0.9em 1em;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.8em;
}
.info {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.name {
font-weight: 600;
}
.you {
color: #42af38;
font-weight: 400;
font-size: 0.85em;
}
.email {
color: #9aa0a6;
font-size: 0.85em;
}
.controls {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
.role-form {
display: flex;
gap: 0.4em;
}
.role-select {
width: auto;
padding: 0.4em 0.5em;
}
.reset-form {
flex-basis: 100%;
display: flex;
gap: 0.5em;
margin-top: 0.3em;
}
.reset-form .admin-input {
flex: 1;
}
@media (max-width: 620px) {
.fields {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,40 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import type { Actions, PageServerLoad } from './$types';
import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth';
export const load: PageServerLoad = ({ locals }) => {
if (locals.user) {
throw redirect(303, '/admin');
}
};
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

@ -1,113 +0,0 @@
<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: none;
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
}
input:focus-visible {
outline: 2px solid rgba(66, 175, 56, 0.6);
outline-offset: 1px;
}
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

@ -1,10 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
export const POST: RequestHandler = ({ cookies }) => {
const token = cookies.get(SESSION_COOKIE);
if (token) invalidateSession(token);
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/admin/login');
};

View File

@ -1,19 +0,0 @@
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

@ -1,139 +0,0 @@
<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

@ -1,12 +0,0 @@
import type { PageServerLoad } from './$types';
import { listPosts } from '$lib/server/content';
export const load: PageServerLoad = () => ({
posts: listPosts(true).map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
coverUrl: p.coverImagePath ? `/uploads/${p.coverImagePath}` : null,
date: (p.publishedAt ?? p.createdAt).toISOString()
}))
});

View File

@ -1,108 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString('sl-SI');
</script>
<svelte:head>
<title>Novice — InteliDom d.o.o.</title>
</svelte:head>
<section class="news-page">
<h1>Novice</h1>
{#if data.posts.length === 0}
<p class="notice">Trenutno ni objav.</p>
{:else}
<div class="news-grid">
{#each data.posts as post (post.slug)}
<a class="post-card" href="/news/{post.slug}">
{#if post.coverUrl}
<div class="post-cover"><img src={post.coverUrl} alt="" loading="lazy" /></div>
{/if}
<div class="post-body">
<span class="post-date">{fmtDate(post.date)}</span>
<h3>{post.title}</h3>
{#if post.excerpt}<p>{post.excerpt}</p>{/if}
</div>
</a>
{/each}
</div>
{/if}
</section>
<style>
.news-page {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
gap: 1.5em;
}
h1 {
margin: 0;
}
.notice {
color: #9aa0a6;
}
.news-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.2em;
}
.post-card {
background: #11161a;
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
transition: transform 0.2s ease;
}
.post-card:hover {
transform: translateY(-3px);
}
.post-cover {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.post-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-body {
padding: 1em;
display: flex;
flex-direction: column;
gap: 0.4em;
}
.post-date {
color: #9aa0a6;
font-size: 0.8em;
}
.post-body h3 {
margin: 0;
font-size: 1.1em;
}
.post-body p {
margin: 0;
color: #c7c9cf;
font-size: 0.92em;
}
</style>

View File

@ -1,19 +0,0 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPublishedPostBySlug } from '$lib/server/content';
export const load: PageServerLoad = ({ params }) => {
const post = getPublishedPostBySlug(params.slug);
if (!post) throw error(404, 'Objava ne obstaja.');
return {
post: {
title: post.title,
date: (post.publishedAt ?? post.createdAt).toISOString(),
coverUrl: post.coverImagePath ? `/uploads/${post.coverImagePath}` : null,
paragraphs: post.body
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean)
}
};
};

View File

@ -1,75 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString('sl-SI');
</script>
<svelte:head>
<title>{data.post.title} — InteliDom d.o.o.</title>
</svelte:head>
<article class="post">
<a class="back" href="/news">← Nazaj na novice</a>
<h1>{data.post.title}</h1>
<span class="date">{fmtDate(data.post.date)}</span>
{#if data.post.coverUrl}
<img class="cover" src={data.post.coverUrl} alt="" />
{/if}
<div class="body">
{#each data.post.paragraphs as para}
<p>{para}</p>
{/each}
</div>
</article>
<style>
.post {
width: 100%;
max-width: 760px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
gap: 0.6em;
}
.back {
color: #9aa0a6;
text-decoration: none;
font-size: 0.9em;
}
.back:hover {
color: #e5e4ea;
}
h1 {
margin: 0.4em 0 0;
}
.date {
color: #9aa0a6;
font-size: 0.9em;
}
.cover {
width: 100%;
border-radius: 12px;
margin-top: 0.6em;
}
.body {
margin-top: 0.6em;
font-size: 1.05em;
line-height: 1.7;
}
.body p {
white-space: pre-line;
color: #d8d9de;
margin: 0 0 1em;
}
</style>

View File

@ -1,41 +0,0 @@
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 +0,0 @@
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g transform="matrix(6.23174,0,0,-6.22835,-179.599,2542.96)"><path d="M44.023,392.574C40.848,392.574 37.906,391.609 35.473,389.961L69.902,370.082L104.328,389.961C101.895,391.613 98.953,392.574 95.781,392.574L44.023,392.574ZM109.586,383.754L71.93,362.012C71.309,361.652 70.629,361.48 69.957,361.473L69.844,361.473C69.172,361.48 68.492,361.652 67.871,362.012L30.215,383.754C29.32,381.816 28.82,379.656 28.82,377.375L28.82,357C28.82,348.578 35.602,341.797 44.023,341.797L95.781,341.797C104.199,341.797 110.98,348.578 110.98,357L110.98,377.375C110.98,379.656 110.48,381.816 109.586,383.754Z" style="fill:white;"/></g></svg>

Before

Width:  |  Height:  |  Size: 909 B

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-static';
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,8 +8,12 @@ const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
// Node server adapter — the app runs as a Node process (behind nginx). // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
adapter: adapter() // 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'
})
} }
}; };

View File

@ -2,13 +2,5 @@ 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']
}
}); });