From 7ff2ca667a8b15de578aeea2b6f1eb4df611016d Mon Sep 17 00:00:00 2001 From: MaticIvesic Date: Mon, 3 Aug 2026 18:32:09 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + drizzle/0001_confused_forge.sql | 42 ++ drizzle/meta/0001_snapshot.json | 429 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/lib/Main.svelte | 45 -- src/lib/Service.svelte | 77 ---- src/lib/Slideshow.svelte | 79 ++++ src/lib/ZoomInImage.svelte | 45 -- src/lib/assets/gallery_icon.svg | 1 + .../assets/intelidom_admin_banner.afdesign | Bin 0 -> 18865 bytes src/lib/assets/intelidom_admin_banner.svg | 1 + src/lib/server/content.ts | 267 +++++++++++ src/lib/server/db/schema.ts | 69 +++ src/routes/+page.server.ts | 29 ++ src/routes/+page.svelte | 271 ++++++++++- src/routes/admin/(dashboard)/+layout.svelte | 13 +- .../admin/(dashboard)/banner/+page.server.ts | 35 ++ .../admin/(dashboard)/banner/+page.svelte | 121 +++++ .../admin/(dashboard)/contact/+page.server.ts | 17 + .../admin/(dashboard)/contact/+page.svelte | 44 +- .../admin/(dashboard)/news/+page.server.ts | 36 ++ .../admin/(dashboard)/news/+page.svelte | 108 ++++- .../(dashboard)/news/[id]/+page.server.ts | 57 +++ .../admin/(dashboard)/news/[id]/+page.svelte | 134 ++++++ .../(dashboard)/services/+page.server.ts | 40 ++ .../admin/(dashboard)/services/+page.svelte | 156 ++++++- .../(dashboard)/services/[id]/+page.server.ts | 51 +++ .../(dashboard)/services/[id]/+page.svelte | 120 +++++ src/routes/news/+page.server.ts | 12 + src/routes/news/+page.svelte | 108 +++++ src/routes/news/[slug]/+page.server.ts | 19 + src/routes/news/[slug]/+page.svelte | 75 +++ 32 files changed, 2321 insertions(+), 190 deletions(-) create mode 100644 drizzle/0001_confused_forge.sql create mode 100644 drizzle/meta/0001_snapshot.json delete mode 100644 src/lib/Main.svelte delete mode 100644 src/lib/Service.svelte create mode 100644 src/lib/Slideshow.svelte delete mode 100644 src/lib/ZoomInImage.svelte create mode 100644 src/lib/assets/gallery_icon.svg create mode 100644 src/lib/assets/intelidom_admin_banner.afdesign create mode 100644 src/lib/assets/intelidom_admin_banner.svg create mode 100644 src/lib/server/content.ts create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/banner/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/banner/+page.svelte create mode 100644 src/routes/admin/(dashboard)/contact/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/news/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/news/[id]/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/news/[id]/+page.svelte create mode 100644 src/routes/admin/(dashboard)/services/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/services/[id]/+page.server.ts create mode 100644 src/routes/admin/(dashboard)/services/[id]/+page.svelte create mode 100644 src/routes/news/+page.server.ts create mode 100644 src/routes/news/+page.svelte create mode 100644 src/routes/news/[slug]/+page.server.ts create mode 100644 src/routes/news/[slug]/+page.svelte diff --git a/.gitignore b/.gitignore index 6cfde11..2eac44b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Affinity Designer lock files +*~lock~ diff --git a/drizzle/0001_confused_forge.sql b/drizzle/0001_confused_forge.sql new file mode 100644 index 0000000..01db809 --- /dev/null +++ b/drizzle/0001_confused_forge.sql @@ -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 +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..e4485e9 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3aa941d..75b8228 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785695615587, "tag": "0000_silly_bushwacker", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785773840324, + "tag": "0001_confused_forge", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/Main.svelte b/src/lib/Main.svelte deleted file mode 100644 index 9ee9bd6..0000000 --- a/src/lib/Main.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - -
- -
    -
  • Inštalacija in zagon Loxone sistema za pametno hišo
  • -
  • Montaža in zagon domofonskih sistemov
  • -
  • Odpiranje vrat na pin kodo ali kartico
  • -
-
- -
    -
  • Napeljava podatkovnih kablov za računalniška omrežja
  • -
  • Montaža komunikacijskih omaric
  • -
  • Urejanje komunikacijskih vozlišč
  • -
  • Montaža in konfiguracija WiFi dostopnih točk
  • -
-
- -
    -
  • Napeljava električnih inštalacij
  • -
  • Zamenjava dotrajanih inštalacij
  • -
  • Zamenjava rezdelilnih omaric, varovalk, vtičnic in stikal
  • -
  • Montaža luči
  • -
- -
-
\ No newline at end of file diff --git a/src/lib/Service.svelte b/src/lib/Service.svelte deleted file mode 100644 index 5f9b473..0000000 --- a/src/lib/Service.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - - -
-
-

- {service} -

-
-
- - - -
- -
-
-
\ No newline at end of file diff --git a/src/lib/Slideshow.svelte b/src/lib/Slideshow.svelte new file mode 100644 index 0000000..87b335f --- /dev/null +++ b/src/lib/Slideshow.svelte @@ -0,0 +1,79 @@ + + +{#if images.length > 0} +
+ {#each images as src, i (src)} + + {/each} + {#if images.length > 1} +
+ {#each images as _, i (i)} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/src/lib/ZoomInImage.svelte b/src/lib/ZoomInImage.svelte deleted file mode 100644 index 9e821e3..0000000 --- a/src/lib/ZoomInImage.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - -
- {serviceImageAlt} -
\ No newline at end of file diff --git a/src/lib/assets/gallery_icon.svg b/src/lib/assets/gallery_icon.svg new file mode 100644 index 0000000..72bbfc6 --- /dev/null +++ b/src/lib/assets/gallery_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/assets/intelidom_admin_banner.afdesign b/src/lib/assets/intelidom_admin_banner.afdesign new file mode 100644 index 0000000000000000000000000000000000000000..12755b8cbb9c2f7b6c9090a72ab855bc9dc63877 GIT binary patch literal 18865 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 \ No newline at end of file diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts new file mode 100644 index 0000000..f94efe1 --- /dev/null +++ b/src/lib/server/content.ts @@ -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) }; + } 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(); +} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e1debae..78bc0d5 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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; + +/** Contact settings stored (as JSON) under the "contact" settings key. */ +export type ContactInfo = { + email: string; + phone: string; + address: string; + showMap: boolean; +}; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..51848db --- /dev/null +++ b/src/routes/+page.server.ts @@ -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() }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2c57224..737d48c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,272 @@ -
+{#if data.banner.length > 0} +
+ +
+{/if} + +
+ {#if data.services.length > 0} +
+ {#each data.services as service, i (service.id)} +
+ {#if service.images.length > 0} +
+ +
+ {/if} +
+

{service.title}

+ {#if service.description}

{service.description}

{/if} + {#if service.bullets.length > 0} +
    + {#each service.bullets as bullet}
  • {bullet}
  • {/each} +
+ {/if} +
+
+ {/each} +
+ {/if} + + {#if data.posts.length > 0} +
+

Novice

+ + Vse novice → +
+ {/if} + +
+

Kontakt

+
+
+ {#if data.contact.address} +

{data.contact.address}

+ {/if} + {#if data.contact.email} +

{data.contact.email}

+ {/if} + {#if data.contact.phone} +

{data.contact.phone}

+ {/if} +
+ {#if data.contact.showMap && data.contact.address} +
+ +
+ {/if} +
+
+
+ + diff --git a/src/routes/admin/(dashboard)/+layout.svelte b/src/routes/admin/(dashboard)/+layout.svelte index 196be51..3dfb5a6 100644 --- a/src/routes/admin/(dashboard)/+layout.svelte +++ b/src/routes/admin/(dashboard)/+layout.svelte @@ -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 @@