Compare commits
No commits in common. "main" and "development" have entirely different histories.
main
...
developmen
|
|
@ -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
|
||||
|
|
@ -8,9 +8,6 @@ node_modules
|
|||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Local data (SQLite database + uploaded images)
|
||||
/data
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -24,6 +21,3 @@ Thumbs.db
|
|||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Affinity Designer lock files
|
||||
*~lock~
|
||||
|
|
|
|||
119
ADMIN_SETUP.md
|
|
@ -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.
|
||||
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
14
package.json
|
|
@ -9,26 +9,18 @@
|
|||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"create-admin": "node scripts/create-admin.js"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
"@sveltejs/adapter-static": "^3.0.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}).`);
|
||||
}
|
||||
121
src/app.css
|
|
@ -1,130 +1,11 @@
|
|||
body {
|
||||
background-color: #0a0d0f;
|
||||
color: #e5e4ea;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Grow the page content so the footer is always pushed to the bottom,
|
||||
even when a page has very little content. */
|
||||
.app-content {
|
||||
flex: 1 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- 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;
|
||||
}
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { User, Session } from '$lib/server/db/schema';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
<script lang="ts">
|
||||
import intelidom_banner from '$lib/assets/intelidom_banner.svg';
|
||||
import gallery_icon from '$lib/assets/gallery_icon.svg';
|
||||
import HeaderContact from './HeaderContact.svelte';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.header-content{
|
||||
div{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -16,18 +15,26 @@
|
|||
top: 0;
|
||||
backdrop-filter: blur(7px);
|
||||
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;
|
||||
width: 15em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-link{
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 900px){
|
||||
|
||||
|
|
@ -37,7 +44,7 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-content{
|
||||
div{
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,8 +53,6 @@
|
|||
|
||||
|
||||
<div class="header-content">
|
||||
<a class="banner-link" href="/" aria-label="Domov">
|
||||
<img src={intelidom_banner} alt="intelidom banner">
|
||||
</a>
|
||||
<HeaderContact/>
|
||||
</div>
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
<script>
|
||||
import HeaderContactElement from "./HeaderContactElement.svelte";
|
||||
// Files in static/ are served at the site root — reference them by URL,
|
||||
// don't import them as modules (Vite dev serves them as raw SVG, which
|
||||
// breaks the import and blanks the whole layout).
|
||||
const phone_icon = '/phone.svg';
|
||||
const mail_icon = '/mail.svg';
|
||||
import phone_icon from '$lib/assets/phone.svg'
|
||||
import mail_icon from '$lib/assets/mail.svg'
|
||||
export let customClass = '';
|
||||
</script>
|
||||
|
||||
|
|
@ -20,12 +17,12 @@ export let customClass = '';
|
|||
.header-contact{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0em;
|
||||
gap: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="header-contact {customClass}" >
|
||||
<HeaderContactElement image_src={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={mail_icon} image_alt="Mail icon" link="mailto:info@intelidom.si" text="info@intelidom.si"></HeaderContactElement>
|
||||
<HeaderContactElement image_src={phone_icon} image_alt="Mail icon" link="tel:+3860771166" text="040 77 11 66"></HeaderContactElement>
|
||||
</div>
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
<script>
|
||||
export let image_src;
|
||||
export let image_alt;
|
||||
export let link;
|
||||
export let text;
|
||||
export let icon_color = '#bcc0c2';
|
||||
export let text_color = '#bcc0c2';
|
||||
|
||||
import SvgImage from "./SvgImage.svelte";
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
@ -13,15 +12,10 @@
|
|||
display: flex;
|
||||
gap: 0.75em;
|
||||
align-items: center;
|
||||
padding: 0.3em;
|
||||
border-radius: 0.5em;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
div:hover{
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0.055);
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
a{
|
||||
|
|
@ -30,16 +24,15 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
div{
|
||||
display: flex;
|
||||
gap: 0.75em;
|
||||
align-items: center;
|
||||
img{
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
filter: drop-shadow(0 0 0 var(--icon-color));
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
|
@ -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 |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import intelidom_icon from '$lib/assets/intelidom_icon.svg';
|
||||
import intelidom_banner from '$lib/assets/intelidom_banner.svg';
|
||||
|
||||
import '../app.css';
|
||||
|
||||
import Header from '$lib/Header.svelte';
|
||||
import 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();
|
||||
|
||||
// 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>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -19,14 +20,8 @@
|
|||
<link rel="icon" href={intelidom_icon} />
|
||||
</svelte:head>
|
||||
|
||||
{#if !isAdmin}
|
||||
<Header></Header>
|
||||
{/if}
|
||||
|
||||
<div class="app-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if !isAdmin}
|
||||
<Main></Main>
|
||||
<Footer></Footer>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
<!-- /admin redirects to /admin/gallery in +page.server.ts; this never renders. -->
|
||||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}))
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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 |
|
|
@ -1,4 +1,4 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
|
|
@ -8,8 +8,12 @@ const config = {
|
|||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// Node server adapter — the app runs as a Node process (behind nginx).
|
||||
adapter: adapter()
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter({
|
||||
fallback: 'index.html'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
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']
|
||||
}
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
|
|
|
|||