Compare commits

..

8 Commits

Author SHA1 Message Date
Matic Ivešić ae17112fc7 Home: uppercase section titles, center contact (email/tel over map)
- Uppercase the section titles (Galerija/Novice/Kontakt)
- Restructure the contact card into a centered column: email + phone side by
  side on top, map below, address under the map

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 17:45:12 +02:00
Matic Ivešić 8a8f19133f Home: gallery preview grid, side-by-side contact, larger section titles
- Add a homepage gallery preview: "Galerija" heading + 4-wide image grid that
  fades out toward the bottom, linking to /gallery (up to 12 images)
- Put contact email + phone side by side
- Make section titles (Galerija/Novice/Kontakt) larger than service names
- listGalleryImages() added to the content service

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 19:06:06 +02:00
Matic Ivešić d5368067d6 Home: contact card platform + mail/phone icons, gallery icon in nav
- Put the Kontakt section on a rounded card ("platform")
- Add mail/phone icons (via SvgImage mask) next to the email and phone
- Use gallery_icon.svg in the top-nav Galerija button
- Scope the header banner-image CSS to .banner-link so the nav icon isn't resized

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:51:33 +02:00
Matic Ivešić a6076fbbf3 Service UX: images in create form, original block look + hover zoom, styled file input
- Add image picker to the "Dodaj storitev" form (upload images while creating)
- Restore original service layout on the homepage: centered uppercase title
  above, rounded image beside text, alternating sides; image zooms on hover
- Slideshow gains a hoverZoom option (scale on hover)
- Style native file inputs (::file-selector-button) to match the dashboard UI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:44:02 +02:00
Matic Ivešić 7ff2ca667a Phases 3-5: banner slideshow, services CMS, blog, contact
Content model (SQLite/Drizzle): banner_images, services + service_images,
news_posts, settings (migration 0001).

- Reusable cross-fading Slideshow component (banner + per-service images)
- Banner (new "Pasica" tab): upload/reorder/delete hero images; homepage shows
  an auto-advancing full-width slideshow only when images exist
- Services CMS: create/edit/delete/reorder services (name, description, bullet
  list) with per-service image management; homepage renders them with rotating
  images in an alternating layout (replaces hardcoded Main/Service components)
- Blog: admin CRUD with draft/publish + cover image; public /news list and
  /news/[slug] article (plain-text paragraphs); latest posts teased on the home
- Contact: editable email/phone/address + optional embedded map; shown at the
  bottom of the homepage
- Homepage rebuilt as data-driven sections; shared content service in
  src/lib/server/content.ts
- Remove now-unused Main.svelte, Service.svelte, ZoomInImage.svelte

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:32:09 +02:00
Matic Ivešić a1342e4fda Dashboard polish: banner brand, borderless forms, fix sidebar clipping
- Use intelidom_banner.svg as the sidebar brand instead of text
- Remove borders from admin cards, inputs, user rows, login inputs and
  ghost buttons (subtle fills / focus outlines instead)
- Fix sidebar clipping: height:100vh + padding overflowed the viewport;
  add box-sizing:border-box (+ overflow-y safety), drop divider borders

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:23:13 +02:00
Matic Ivešić 22af32c722 Phase 2: multi-tab dashboard shell + user management
- Restructure /admin into a (dashboard) route group with a sidebar shell
  (Galerija / Storitve / Novice / Kontakt / Uporabniki) and active-tab state
- Hide the public site header/footer on all /admin routes
- Centralize auth in hooks.server.ts so page loads, form actions AND endpoints
  are all gated (layout load guards don't cover actions)
- Users section (admin-only): add user, change role, reset password, delete;
  safeguards prevent self-delete and removing the last admin; server-side role
  checks (editors get 403 even if UI hidden)
- Login redirects already-authed users; logout moved to /admin/logout endpoint
- Gallery management moved under /admin/gallery; shared .admin-* styles in app.css
- Fix flexbox overflow in the dashboard main column (min-width:0, box-sizing)
- Placeholder tabs for Services/News/Contact (Phases 3-5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:13:02 +02:00
Matic Ivešić 6bc5fffda2 Phase 1: self-hosted backend (adapter-node + SQLite) replacing Supabase
Migrate the gallery/admin off the static + Supabase setup to a self-hosted
full-stack app that runs on the home server behind nginx.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:54:06 +02:00
66 changed files with 6277 additions and 314 deletions

6
.env.example Normal file
View File

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

6
.gitignore vendored
View File

@ -8,6 +8,9 @@ node_modules
/.svelte-kit /.svelte-kit
/build /build
# Local data (SQLite database + uploaded images)
/data
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@ -21,3 +24,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Affinity Designer lock files
*~lock~

119
ADMIN_SETUP.md Normal file
View File

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

10
drizzle.config.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,429 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f9b24229-b6a3-481b-9bb6-5028ef961005",
"prevId": "937f8d50-c54c-47ab-8b71-933736e4cfdb",
"tables": {
"banner_images": {
"name": "banner_images",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"gallery_images": {
"name": "gallery_images",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"news_posts": {
"name": "news_posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"cover_image_path": {
"name": "cover_image_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published": {
"name": "published",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"news_posts_slug_unique": {
"name": "news_posts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"service_images": {
"name": "service_images",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"service_id": {
"name": "service_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"service_images_service_id_services_id_fk": {
"name": "service_images_service_id_services_id_fk",
"tableFrom": "service_images",
"tableTo": "services",
"columnsFrom": [
"service_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"services": {
"name": "services",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bullets": {
"name": "bullets",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editor'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,20 @@
{
"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
}
]
}

1985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

@ -1,6 +1,6 @@
body { body {
background-color: #0a0d0f; background-color: #0a0d0f;
color: #e5e4ea; color: #e5e4ea;
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -8,4 +8,123 @@ body {
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: 2em; gap: 2em;
min-height: 100vh;
box-sizing: border-box;
}
/* Grow the page content so the footer is always pushed to the bottom,
even when a page has very little content. */
.app-content {
flex: 1 0 auto;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
/* ---- Shared admin dashboard UI ---- */
.admin-section {
display: flex;
flex-direction: column;
gap: 1.2em;
}
.admin-section h1 {
margin: 0;
font-weight: 600;
font-size: 1.5em;
}
.admin-section h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
.admin-card {
background: #11161a;
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
}
.admin-card label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
.admin-input {
background: #0a0d0f;
border: none;
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
}
.admin-input:focus-visible {
outline: 2px solid rgba(66, 175, 56, 0.6);
outline-offset: 1px;
}
.admin-btn {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.55em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 0.95em;
cursor: pointer;
align-self: flex-start;
}
.admin-btn:disabled {
opacity: 0.6;
cursor: default;
}
.admin-btn-danger {
background: rgba(220, 40, 40, 0.9);
color: #fff;
}
.admin-btn-ghost {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
}
/* Style the native file input to match the rest of the dashboard UI. */
input[type="file"] {
color: #9aa0a6;
font-family: inherit;
font-size: 0.9em;
}
input[type="file"]::file-selector-button {
background: rgba(255, 255, 255, 0.08);
color: #e5e4ea;
border: none;
border-radius: 8px;
padding: 0.5em 1em;
margin-right: 0.9em;
font-family: inherit;
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
}
input[type="file"]::file-selector-button:hover {
background: rgba(66, 175, 56, 0.25);
}
.admin-msg {
margin: 0;
font-size: 0.9em;
color: #42af38;
}
.admin-error {
margin: 0;
font-size: 0.9em;
color: #ff6b6b;
}
.admin-notice {
color: #9aa0a6;
} }

7
src/app.d.ts vendored
View File

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

40
src/hooks.server.ts Normal file
View File

@ -0,0 +1,40 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const result = validateSessionToken(token);
if (result) {
event.locals.user = result.user;
event.locals.session = result.session;
// Keep the cookie in sync with the (possibly refreshed) session expiry.
event.cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: result.session.expiresAt
});
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
event.locals.user = null;
event.locals.session = null;
}
} else {
event.locals.user = null;
event.locals.session = null;
}
// Protect the whole dashboard here (except the login page) so that page
// loads, form actions AND endpoints are all gated — layout `load` guards do
// not run before form actions.
const { pathname } = event.url;
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && !event.locals.user) {
throw redirect(303, '/admin/login');
}
return resolve(event);
};

View File

@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import intelidom_banner from '$lib/assets/intelidom_banner.svg'; import intelidom_banner from '$lib/assets/intelidom_banner.svg';
import gallery_icon from '$lib/assets/gallery_icon.svg';
import HeaderContact from './HeaderContact.svelte'; import HeaderContact from './HeaderContact.svelte';
</script> </script>
<style> <style>
div{ .header-content{
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -18,11 +19,36 @@
z-index: 1; z-index: 1;
} }
div>img{ .banner-link img{
padding: 1em; padding: 1em;
width: 15em; width: 15em;
z-index: 1; z-index: 1;
} }
.nav{
display: flex;
gap: 1.5em;
padding: 0.5em 0;
z-index: 1;
}
.nav-link{
align-items: center;
color: #e5e4ea;
text-decoration: none;
font-weight: 500;
}
.nav-icon{
width: 2em;
height: 2em;
display: block;
}
.banner-link{
display: inline-flex;
}
@media (min-width: 900px){ @media (min-width: 900px){
:global(.header-contact){ :global(.header-contact){
@ -31,7 +57,13 @@
z-index: 1; z-index: 1;
} }
div{ .nav{
position: absolute;
left: 2em;
top: 1.5em;
}
.header-content{
padding: 0; padding: 0;
} }
} }
@ -40,6 +72,13 @@
<div class="header-content"> <div class="header-content">
<img src={intelidom_banner} alt="intelidom banner"> <nav class="nav">
<a class="nav-link" href="/gallery">
<img class="nav-icon" src={gallery_icon} alt="" />
</a>
</nav>
<a class="banner-link" href="/" aria-label="Domov">
<img src={intelidom_banner} alt="intelidom banner">
</a>
<HeaderContact/> <HeaderContact/>
</div> </div>

View File

@ -1,7 +1,10 @@
<script> <script>
import HeaderContactElement from "./HeaderContactElement.svelte"; import HeaderContactElement from "./HeaderContactElement.svelte";
import phone_icon from '/phone.svg' // Files in static/ are served at the site root — reference them by URL,
import mail_icon from '/mail.svg' // don't import them as modules (Vite dev serves them as raw SVG, which
// breaks the import and blanks the whole layout).
const phone_icon = '/phone.svg';
const mail_icon = '/mail.svg';
export let customClass = ''; export let customClass = '';
</script> </script>

View File

@ -1,45 +0,0 @@
<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;
}
li{
margin-bottom: 1em;
}
</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);" 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>

View File

@ -1,77 +0,0 @@
<script>
export let service = 'Lorem Ipsum';
export let serviceImage = '/default_image.jpg';
export let serviceImageAlt = 'Image';
export let imageOnRight = false;
import ZoomInImage from '$lib/ZoomInImage.svelte'
</script>
<style>
.service{
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.service-content{
width: 100%;
height: 100%;
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: 2em;
}
.image-right{
flex-direction: column-reverse;
}
.service-text{
width: 100%;
font-size: 1.3em;
color: #bcc0c2;
}
.service-name{
text-transform: uppercase;
}
h2{
text-align: center;
width: 100%;
}
@media (min-width: 900px){
.service-content{
flex-direction: row;
align-items: start;
}
.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}>
<ZoomInImage serviceImage={serviceImage} serviceImageAlt={serviceImageAlt}>
</ZoomInImage>
<div class="service-text">
<slot />
</div>
</div>
</div>

90
src/lib/Slideshow.svelte Normal file
View File

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

View File

@ -1,45 +0,0 @@
<script>
export let serviceImage = '/default_image.jpg';
export let serviceImageAlt = 'Image';
</script>
<style>
.image{
position: relative;
display: flex;
justify-content: center;
align-items: center;
border-radius: 2em;
width: 80%;
height: 0;
padding-top: 60%;
overflow: hidden;
z-index: 0;
}
.image>img{
position: absolute;
top: 0;
left: 0;
width: 100%;
object-fit: cover;
transition: 0.2s;
}
.image>img:hover{
transform: scale(1.1);
}
@media (min-width: 900px){
.image{
padding-top: 33%;
}
}
</style>
<div class="image">
<img src={serviceImage} alt={serviceImageAlt}>
</div>

Binary file not shown.

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

65
src/lib/server/auth.ts Normal file
View File

@ -0,0 +1,65 @@
import { hash, verify } from '@node-rs/argon2';
import { randomBytes, createHash } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { db } from './db';
import { sessions, users, type Session, type User } from './db/schema';
export const SESSION_COOKIE = 'session';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
export function hashPassword(password: string): Promise<string> {
return hash(password);
}
export function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password);
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
/** Create a session for a user and return the raw token to store in the cookie. */
export function createSession(userId: string): { token: string; expiresAt: Date } {
const token = randomBytes(32).toString('hex');
const id = sha256(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.insert(sessions).values({ id, userId, expiresAt }).run();
return { token, expiresAt };
}
/** Look up the session/user for a cookie token, refreshing expiry as it nears the end. */
export function validateSessionToken(token: string): { user: User; session: Session } | null {
const id = sha256(token);
const row = db
.select()
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, id))
.get();
if (!row) return null;
const { sessions: session, users: user } = row;
if (Date.now() >= session.expiresAt.getTime()) {
db.delete(sessions).where(eq(sessions.id, id)).run();
return null;
}
// Sliding expiration: extend when less than half the lifetime remains.
if (Date.now() >= session.expiresAt.getTime() - SESSION_TTL_MS / 2) {
session.expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.update(sessions).set({ expiresAt: session.expiresAt }).where(eq(sessions.id, id)).run();
}
return { user, session };
}
export function invalidateSession(token: string): void {
db.delete(sessions).where(eq(sessions.id, sha256(token))).run();
}
export function findUserByEmail(email: string): User | undefined {
return db.select().from(users).where(eq(users.email, email.toLowerCase())).get();
}

277
src/lib/server/content.ts Normal file
View File

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

View File

@ -0,0 +1,19 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { env } from '$env/dynamic/private';
import * as schema from './schema';
const dbPath = env.DATABASE_PATH ?? './data/app.db';
mkdirSync(dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
// Apply any pending migrations on startup so a fresh deploy self-initialises.
migrate(db, { migrationsFolder: './drizzle' });

112
src/lib/server/db/schema.ts Normal file
View File

@ -0,0 +1,112 @@
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;
};

40
src/lib/server/storage.ts Normal file
View File

@ -0,0 +1,40 @@
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { join, dirname, resolve, sep } from 'node:path';
import { randomUUID } from 'node:crypto';
import { env } from '$env/dynamic/private';
/** Root directory for user-uploaded files (served at /uploads, or by nginx in prod). */
export const UPLOADS_DIR = env.UPLOADS_DIR ?? './data/uploads';
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif']);
/** Save an uploaded image under `<subdir>/` and return its path relative to UPLOADS_DIR. */
export async function saveImage(subdir: string, file: File): Promise<string> {
const ext = (file.name.split('.').pop() ?? '').toLowerCase();
const safeExt = IMAGE_EXTENSIONS.has(ext) ? ext : 'jpg';
const relativePath = `${subdir}/${randomUUID()}.${safeExt}`;
const absolutePath = join(UPLOADS_DIR, relativePath);
mkdirSync(dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, Buffer.from(await file.arrayBuffer()));
return relativePath;
}
export function deleteUpload(relativePath: string): void {
try {
unlinkSync(resolveUploadPath(relativePath));
} catch {
// Already gone — nothing to do.
}
}
/** Resolve a relative upload path to an absolute one, guarding against path traversal. */
export function resolveUploadPath(relativePath: string): string {
const root = resolve(UPLOADS_DIR);
const target = resolve(root, relativePath);
if (target !== root && !target.startsWith(root + sep)) {
throw new Error('Invalid upload path');
}
return target;
}

62
src/lib/server/users.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { fail } from '@sveltejs/kit';
import { asc, desc, eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { galleryImages } from '$lib/server/db/schema';
import { saveImage, deleteUpload } from '$lib/server/storage';
function listImages() {
return db
.select()
.from(galleryImages)
.orderBy(asc(galleryImages.sortOrder), desc(galleryImages.createdAt))
.all()
.map((image) => ({
id: image.id,
url: `/uploads/${image.imagePath}`,
caption: image.caption
}));
}
export const load: PageServerLoad = () => {
return { images: listImages() };
};
export const actions: Actions = {
upload: async ({ request }) => {
const form = await request.formData();
const caption = String(form.get('caption') ?? '').trim() || null;
const files = form
.getAll('images')
.filter((entry): entry is File => entry instanceof File && entry.size > 0);
if (files.length === 0) {
return fail(400, { error: 'Izberite vsaj eno sliko.' });
}
let count = 0;
for (const file of files) {
const imagePath = await saveImage('gallery', file);
db.insert(galleryImages).values({ id: randomUUID(), imagePath, caption }).run();
count++;
}
return { success: `Naloženih slik: ${count}.` };
},
delete: async ({ request }) => {
const form = await request.formData();
const id = String(form.get('id') ?? '');
const row = db.select().from(galleryImages).where(eq(galleryImages.id, id)).get();
if (row) {
deleteUpload(row.imagePath);
db.delete(galleryImages).where(eq(galleryImages.id, id)).run();
}
return { success: 'Slika izbrisana.' };
}
};

View File

@ -0,0 +1,106 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<section class="admin-section">
<h1>Galerija</h1>
<form
class="admin-card"
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Dodaj sliko</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<label>
Opis (neobvezno)
<input class="admin-input" type="text" name="caption" placeholder="npr. Montaža omarice" />
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit" disabled={uploading}>
{uploading ? 'Nalaganje…' : 'Naloži'}
</button>
</form>
<h2>Slike v galeriji</h2>
{#if data.images.length === 0}
<p class="admin-notice">Ni slik. Naložite prvo zgoraj.</p>
{:else}
<div class="grid">
{#each data.images as image (image.id)}
<div class="tile">
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={image.id} />
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1em;
}
.tile {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile .cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.5em 0.6em;
font-size: 0.8em;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.tile .del {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 40, 40, 0.9);
color: #fff;
border: none;
padding: 0.3em 0.6em;
font-size: 0.8em;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import type { Actions, PageServerLoad } from './$types';
import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth';
export const load: PageServerLoad = ({ locals }) => {
if (locals.user) {
throw redirect(303, '/admin');
}
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const email = String(form.get('email') ?? '')
.trim()
.toLowerCase();
const password = String(form.get('password') ?? '');
if (!email || !password) {
return fail(400, { error: 'Vnesite e-naslov in geslo.', email });
}
const user = findUserByEmail(email);
if (!user || !(await verifyPassword(user.passwordHash, password))) {
return fail(400, { error: 'Napačen e-naslov ali geslo.', email });
}
const { token, expiresAt } = createSession(user.id);
cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: expiresAt
});
throw redirect(303, '/admin');
}
};

View File

@ -0,0 +1,113 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let submitting = $state(false);
</script>
<svelte:head>
<title>Prijava — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<section class="login-wrap">
<form
class="card login"
method="POST"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<h2>Prijava</h2>
<label>
E-naslov
<input type="email" name="email" value={form?.email ?? ''} autocomplete="username" required />
</label>
<label>
Geslo
<input type="password" name="password" autocomplete="current-password" required />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
<button type="submit" disabled={submitting}>{submitting ? 'Prijavljanje…' : 'Prijava'}</button>
</form>
</section>
<style>
.login-wrap {
flex: 1 0 auto;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 2em 1em;
}
.card {
background: #11161a;
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
width: 360px;
max-width: 100%;
}
h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
input {
background: #0a0d0f;
border: none;
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
}
input:focus-visible {
outline: 2px solid rgba(66, 175, 56, 0.6);
outline-offset: 1px;
}
button {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.6em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 1em;
cursor: pointer;
align-self: flex-start;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.error {
color: #ff6b6b;
margin: 0;
font-size: 0.9em;
}
</style>

View File

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

View File

@ -0,0 +1,19 @@
import { asc, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { galleryImages } from '$lib/server/db/schema';
export const load: PageServerLoad = () => {
const images = db
.select()
.from(galleryImages)
.orderBy(asc(galleryImages.sortOrder), desc(galleryImages.createdAt))
.all()
.map((image) => ({
id: image.id,
url: `/uploads/${image.imagePath}`,
caption: image.caption
}));
return { images };
};

View File

@ -0,0 +1,139 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type GalleryImage = PageData['images'][number];
let selected: GalleryImage | null = $state(null);
</script>
<svelte:head>
<title>Galerija — InteliDom d.o.o.</title>
</svelte:head>
<section class="gallery">
<h1>Galerija</h1>
{#if data.images.length === 0}
<p class="notice">Galerija je trenutno prazna.</p>
{:else}
<div class="grid">
{#each data.images as img (img.id)}
<button class="tile" onclick={() => (selected = img)} aria-label="Povečaj sliko">
<img src={img.url} alt={img.caption ?? 'Slika iz galerije'} loading="lazy" />
{#if img.caption}
<span class="caption">{img.caption}</span>
{/if}
</button>
{/each}
</div>
{/if}
</section>
{#if selected}
<div
class="lightbox"
role="button"
tabindex="0"
aria-label="Zapri"
onclick={() => (selected = null)}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') selected = null;
}}
>
<img src={selected.url} alt={selected.caption ?? 'Slika iz galerije'} />
{#if selected.caption}
<p class="lightbox-caption">{selected.caption}</p>
{/if}
</div>
{/if}
<style>
.gallery {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5em;
}
h1 {
font-weight: 600;
margin: 0;
}
.notice {
color: #9aa0a6;
}
.grid {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
}
.tile {
position: relative;
padding: 0;
border: none;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.35s ease;
}
.tile:hover img {
transform: scale(1.06);
}
.caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.6em 0.8em;
font-size: 0.85em;
color: #fff;
text-align: left;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
}
.lightbox {
position: fixed;
inset: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1em;
padding: 2em;
cursor: zoom-out;
}
.lightbox img {
max-width: 92vw;
max-height: 82vh;
object-fit: contain;
border-radius: 8px;
}
.lightbox-caption {
color: #e5e4ea;
margin: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,41 @@
import { error } from '@sveltejs/kit';
import { readFileSync, statSync } from 'node:fs';
import { extname } from 'node:path';
import type { RequestHandler } from './$types';
import { resolveUploadPath } from '$lib/server/storage';
// In production nginx serves /uploads/* straight off disk; this endpoint is the
// dev fallback (and a safety net) so uploaded images resolve without nginx.
const CONTENT_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif'
};
export const GET: RequestHandler = ({ params }) => {
let absolutePath: string;
try {
absolutePath = resolveUploadPath(params.path);
} catch {
throw error(400, 'Invalid path');
}
let data: Buffer;
try {
statSync(absolutePath);
data = readFileSync(absolutePath);
} catch {
throw error(404, 'Not found');
}
const type = CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream';
return new Response(new Uint8Array(data), {
headers: {
'Content-Type': type,
'Cache-Control': 'public, max-age=2592000'
}
});
};

View File

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

View File

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