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>
This commit is contained in:
parent
a1342e4fda
commit
7ff2ca667a
|
|
@ -24,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~
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1785695615587,
|
"when": 1785695615587,
|
||||||
"tag": "0000_silly_bushwacker",
|
"tag": "0000_silly_bushwacker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785773840324,
|
||||||
|
"tag": "0001_confused_forge",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script lang="ts">
|
||||||
|
// Cross-fading auto-advancing slideshow. Fills its parent (which sets the size).
|
||||||
|
let {
|
||||||
|
images = [],
|
||||||
|
interval = 4000,
|
||||||
|
alt = '',
|
||||||
|
fit = 'cover'
|
||||||
|
}: { images: string[]; interval?: number; alt?: string; fit?: 'cover' | 'contain' } = $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">
|
||||||
|
{#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideshow img.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.8em;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.55em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,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>
|
|
||||||
|
|
@ -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.
|
|
@ -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 |
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { and, asc, desc, eq, ne } from 'drizzle-orm';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { db } from './db';
|
||||||
|
import {
|
||||||
|
bannerImages,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -35,9 +35,78 @@ export const galleryImages = sqliteTable('gallery_images', {
|
||||||
.$defaultFn(() => new Date())
|
.$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 User = typeof users.$inferSelect;
|
||||||
export type Session = typeof sessions.$inferSelect;
|
export type Session = typeof sessions.$inferSelect;
|
||||||
export type GalleryImage = typeof galleryImages.$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). */
|
/** User shape safe to expose to the browser (no password hash). */
|
||||||
export type PublicUser = Pick<User, 'id' | 'email' | 'name' | 'role'>;
|
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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getContact, listBannerImages, listPosts, listServices } from '$lib/server/content';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = () => {
|
||||||
|
const banner = listBannerImages().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, posts, contact: getContact() };
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,272 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Main from '$lib/Main.svelte';
|
import Slideshow from '$lib/Slideshow.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>
|
</script>
|
||||||
|
|
||||||
<Main></Main>
|
{#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" class:reverse={i % 2 === 1}>
|
||||||
|
{#if service.images.length > 0}
|
||||||
|
<div class="service-media">
|
||||||
|
<Slideshow images={service.images} alt={service.title} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="service-text">
|
||||||
|
<h2>{service.title}</h2>
|
||||||
|
{#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>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</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-grid">
|
||||||
|
<div class="contact-info">
|
||||||
|
{#if data.contact.address}
|
||||||
|
<p class="address">{data.contact.address}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.contact.email}
|
||||||
|
<p><a href="mailto:{data.contact.email}">{data.contact.email}</a></p>
|
||||||
|
{/if}
|
||||||
|
{#if data.contact.phone}
|
||||||
|
<p><a href="tel:{data.contact.phone.replace(/\s+/g, '')}">{data.contact.phone}</a></p>
|
||||||
|
{/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}
|
||||||
|
</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: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-media {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #11161a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-text h2 {
|
||||||
|
margin: 0 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: #c7c9cf;
|
||||||
|
margin: 0 0 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-text ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-text li {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: #c7c9cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #c7c9cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a {
|
||||||
|
color: #e5e4ea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info a:hover {
|
||||||
|
color: #42af38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.service {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service.reverse {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-media {
|
||||||
|
width: 48%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import intelidom_banner from '$lib/assets/intelidom_banner.svg';
|
import intelidom_admin_banner from '$lib/assets/intelidom_admin_banner.svg';
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/admin/gallery', label: 'Galerija' },
|
{ href: '/admin/gallery', label: 'Galerija' },
|
||||||
|
{ href: '/admin/banner', label: 'Pasica' },
|
||||||
{ href: '/admin/services', label: 'Storitve' },
|
{ href: '/admin/services', label: 'Storitve' },
|
||||||
{ href: '/admin/news', label: 'Novice' },
|
{ href: '/admin/news', label: 'Novice' },
|
||||||
{ href: '/admin/contact', label: 'Kontakt' }
|
{ href: '/admin/contact', label: 'Kontakt' }
|
||||||
|
|
@ -24,8 +25,7 @@
|
||||||
<div class="dash">
|
<div class="dash">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<a href="/"><img src={intelidom_banner} alt="InteliDom" /></a>
|
<a href="/"><img src={intelidom_admin_banner} alt="InteliDom" /></a>
|
||||||
<span>Admin</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
|
|
@ -96,13 +96,6 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand span {
|
|
||||||
color: #9aa0a6;
|
|
||||||
font-size: 0.75em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,6 +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">
|
<section class="admin-section">
|
||||||
<h1>Kontakt</h1>
|
<h1>Kontakt</h1>
|
||||||
<div class="admin-card">
|
|
||||||
<p class="admin-notice">Urejanje kontaktnih podatkov bo na voljo kmalu (Faza 5).</p>
|
<form class="admin-card" method="POST" action="?/save" use:enhance>
|
||||||
</div>
|
<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>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
textarea.admin-input {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,6 +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">
|
<section class="admin-section">
|
||||||
<h1>Novice</h1>
|
<h1>Novice</h1>
|
||||||
<div class="admin-card">
|
|
||||||
<p class="admin-notice">Urejanje novic bo na voljo kmalu (Faza 4).</p>
|
<form class="admin-card" method="POST" action="?/create" use:enhance>
|
||||||
</div>
|
<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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { deleteUpload } from '$lib/server/storage';
|
||||||
|
import { 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.' });
|
||||||
|
createService({
|
||||||
|
title,
|
||||||
|
description: String(form.get('description') ?? '').trim() || null,
|
||||||
|
bullets: String(form.get('bullets') ?? '').trim() || null
|
||||||
|
});
|
||||||
|
return { success: 'Storitev dodana.' };
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const paths = deleteService(String(form.get('id') ?? ''));
|
||||||
|
for (const p of paths) deleteUpload(p);
|
||||||
|
return { success: 'Storitev izbrisana.' };
|
||||||
|
},
|
||||||
|
|
||||||
|
move: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
moveService(String(form.get('id') ?? ''), form.get('dir') === 'up' ? 'up' : 'down');
|
||||||
|
return { success: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,154 @@
|
||||||
|
<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">
|
<section class="admin-section">
|
||||||
<h1>Storitve</h1>
|
<h1>Storitve</h1>
|
||||||
<div class="admin-card">
|
|
||||||
<p class="admin-notice">
|
<form class="admin-card" method="POST" action="?/create" use:enhance>
|
||||||
Urejanje storitev bo na voljo kmalu (Faza 3). Trenutno so storitve zapisane v kodi.
|
<h2>Dodaj storitev</h2>
|
||||||
</p>
|
<label>
|
||||||
</div>
|
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>
|
||||||
|
{#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č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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue