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:
Matic Ivešić 2026-08-03 18:32:09 +02:00
parent a1342e4fda
commit 7ff2ca667a
32 changed files with 2321 additions and 190 deletions

3
.gitignore vendored
View File

@ -24,3 +24,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Affinity Designer lock files
*~lock~

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,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

@ -8,6 +8,13 @@
"when": 1785695615587,
"tag": "0000_silly_bushwacker",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785773840324,
"tag": "0001_confused_forge",
"breakpoints": true
}
]
}

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>

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

@ -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>

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>

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

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

@ -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();
}

View File

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

View File

@ -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() };
};

View File

@ -1,5 +1,272 @@
<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>
<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>

View File

@ -2,12 +2,13 @@
import { page } from '$app/state';
import type { Snippet } from 'svelte';
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();
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' }
@ -24,8 +25,7 @@
<div class="dash">
<aside class="sidebar">
<div class="brand">
<a href="/"><img src={intelidom_banner} alt="InteliDom" /></a>
<span>Admin</span>
<a href="/"><img src={intelidom_admin_banner} alt="InteliDom" /></a>
</div>
<nav>
@ -96,13 +96,6 @@
display: block;
}
.brand span {
color: #9aa0a6;
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.08em;
}
nav {
display: flex;
flex-direction: row;

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

@ -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">
<h1>Kontakt</h1>
<div class="admin-card">
<p class="admin-notice">Urejanje kontaktnih podatkov bo na voljo kmalu (Faza 5).</p>
</div>
<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,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

@ -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">
<h1>Novice</h1>
<div class="admin-card">
<p class="admin-notice">Urejanje novic bo na voljo kmalu (Faza 4).</p>
</div>
<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,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 };
}
};

View File

@ -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">
<h1>Storitve</h1>
<div class="admin-card">
<p class="admin-notice">
Urejanje storitev bo na voljo kmalu (Faza 3). Trenutno so storitve zapisane v kodi.
</p>
</div>
<form class="admin-card" method="POST" action="?/create" use:enhance>
<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>
{#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>
<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,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>