diff --git a/migrations/20260313030304_add_position_page_monitors.ts b/migrations/20260313030304_add_position_page_monitors.ts new file mode 100644 index 00000000..ad0f2b32 --- /dev/null +++ b/migrations/20260313030304_add_position_page_monitors.ts @@ -0,0 +1,19 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn("pages_monitors", "position"); + if (!hasColumn) { + await knex.schema.alterTable("pages_monitors", (table) => { + table.integer("position").unsigned().notNullable().defaultTo(0); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn("pages_monitors", "position"); + if (hasColumn) { + await knex.schema.alterTable("pages_monitors", (table) => { + table.dropColumn("position"); + }); + } +} diff --git a/seeds/pages.ts b/seeds/pages.ts index 3f41c137..c2930ee4 100644 --- a/seeds/pages.ts +++ b/seeds/pages.ts @@ -30,6 +30,7 @@ export async function seed(knex: Knex): Promise { page_id: pageId, monitor_tag: "earth", monitor_settings_json: "", + position: 0, created_at: knex.fn.now(), updated_at: knex.fn.now(), }); @@ -42,6 +43,7 @@ export async function seed(knex: Knex): Promise { page_id: pageId, monitor_tag: "kener", monitor_settings_json: "", + position: 1, created_at: knex.fn.now(), updated_at: knex.fn.now(), }); diff --git a/src/lib/server/controllers/pagesController.ts b/src/lib/server/controllers/pagesController.ts index 346c58f3..9a004cb4 100644 --- a/src/lib/server/controllers/pagesController.ts +++ b/src/lib/server/controllers/pagesController.ts @@ -107,6 +107,7 @@ export async function AddMonitorToPage( page_id: number, monitor_tag: string, monitor_settings_json?: string | null, + position?: number, ): Promise { // Check if page exists const page = await db.getPageById(page_id); @@ -120,13 +121,38 @@ export async function AddMonitorToPage( throw new Error(`Monitor "${monitor_tag}" already exists on this page`); } + // If no position specified, append at end + let finalPosition = position; + if (finalPosition === undefined) { + const existing = await db.getPageMonitors(page_id); + finalPosition = existing.length > 0 ? Math.max(...existing.map((m) => m.position)) + 1 : 0; + } + await db.addMonitorToPage({ page_id, monitor_tag, monitor_settings_json: monitor_settings_json || null, + position: finalPosition, }); } +/** + * Reorder monitors on a page + */ +export async function ReorderPageMonitors(page_id: number, monitor_tags: string[]): Promise { + const page = await db.getPageById(page_id); + if (!page) { + throw new Error(`Page with id ${page_id} not found`); + } + + const monitorPositions = monitor_tags.map((tag, index) => ({ + monitor_tag: tag, + position: index, + })); + + await db.updatePageMonitorPositions(page_id, monitorPositions); +} + /** * Remove monitor from a page */ diff --git a/src/lib/server/db/dbimpl.ts b/src/lib/server/db/dbimpl.ts index 8243ec51..f071bdf0 100644 --- a/src/lib/server/db/dbimpl.ts +++ b/src/lib/server/db/dbimpl.ts @@ -212,6 +212,7 @@ class DbImpl { monitorExistsOnPage!: PagesRepository["monitorExistsOnPage"]; deletePageMonitorsByTag!: PagesRepository["deletePageMonitorsByTag"]; deletePageMonitorsByPageId!: PagesRepository["deletePageMonitorsByPageId"]; + updatePageMonitorPositions!: PagesRepository["updatePageMonitorPositions"]; // ============ Maintenances ============ createMaintenance!: MaintenancesRepository["createMaintenance"]; @@ -554,6 +555,7 @@ class DbImpl { this.monitorExistsOnPage = this.pages.monitorExistsOnPage.bind(this.pages); this.deletePageMonitorsByTag = this.pages.deletePageMonitorsByTag.bind(this.pages); this.deletePageMonitorsByPageId = this.pages.deletePageMonitorsByPageId.bind(this.pages); + this.updatePageMonitorPositions = this.pages.updatePageMonitorPositions.bind(this.pages); } private bindMaintenancesMethods(): void { diff --git a/src/lib/server/db/repositories/pages.ts b/src/lib/server/db/repositories/pages.ts index aae16289..c4267fb1 100644 --- a/src/lib/server/db/repositories/pages.ts +++ b/src/lib/server/db/repositories/pages.ts @@ -65,6 +65,7 @@ export class PagesRepository extends BaseRepository { page_id: data.page_id, monitor_tag: data.monitor_tag, monitor_settings_json: data.monitor_settings_json, + position: data.position ?? 0, created_at: this.knex.fn.now(), updated_at: this.knex.fn.now(), }); @@ -75,7 +76,7 @@ export class PagesRepository extends BaseRepository { } async getPageMonitors(page_id: number): Promise { - return await this.knex("pages_monitors").where("page_id", page_id).orderBy("created_at", "desc"); + return await this.knex("pages_monitors").where("page_id", page_id).orderBy("position", "asc"); } async getPageMonitorsExcludeHidden(page_id: number): Promise { @@ -84,7 +85,7 @@ export class PagesRepository extends BaseRepository { .where("pages_monitors.page_id", page_id) .andWhere("monitors.is_hidden", "NO") .andWhere("monitors.status", "ACTIVE") - .orderBy("pages_monitors.created_at", "desc") + .orderBy("pages_monitors.position", "asc") .select("pages_monitors.*"); } @@ -115,4 +116,17 @@ export class PagesRepository extends BaseRepository { async deletePageMonitorsByPageId(page_id: number): Promise { return await this.knex("pages_monitors").where({ page_id }).del(); } + + async updatePageMonitorPositions( + page_id: number, + monitorPositions: { monitor_tag: string; position: number }[], + ): Promise { + await this.knex.transaction(async (trx) => { + for (const mp of monitorPositions) { + await trx("pages_monitors") + .where({ page_id, monitor_tag: mp.monitor_tag }) + .update({ position: mp.position, updated_at: trx.fn.now() }); + } + }); + } } diff --git a/src/lib/server/types/db.ts b/src/lib/server/types/db.ts index 0115d8ae..308a704c 100644 --- a/src/lib/server/types/db.ts +++ b/src/lib/server/types/db.ts @@ -463,6 +463,7 @@ export interface PageMonitorRecord { page_id: number; monitor_tag: string; monitor_settings_json: string | null; + position: number; created_at: Date; updated_at: Date; } @@ -471,12 +472,14 @@ export interface PageMonitorRecordInsert { page_id: number; monitor_tag: string; monitor_settings_json?: string | null; + position?: number; } export interface PageMonitorRecordTyped { page_id: number; monitor_tag: string; monitor_settings: Record | null; + position: number; created_at: Date; updated_at: Date; } diff --git a/src/routes/(api)/api/v4/pages/+server.ts b/src/routes/(api)/api/v4/pages/+server.ts index 68595e13..a2f56b8c 100644 --- a/src/routes/(api)/api/v4/pages/+server.ts +++ b/src/routes/(api)/api/v4/pages/+server.ts @@ -105,7 +105,7 @@ async function formatPageResponse(page: PageRecord): Promise { page_subheader: page.page_subheader, page_logo: page.page_logo, page_settings: pageSettings, - monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag })), + monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag, position: pm.position })), created_at: formatDateToISO(page.created_at), updated_at: formatDateToISO(page.updated_at), }; @@ -221,11 +221,12 @@ export const POST: RequestHandler = async ({ request }) => { // Add monitors to the page if (body.monitors && Array.isArray(body.monitors)) { - for (const monitorTag of body.monitors) { + for (let i = 0; i < body.monitors.length; i++) { await db.addMonitorToPage({ page_id: createdPage.id, - monitor_tag: monitorTag, + monitor_tag: body.monitors[i], monitor_settings_json: null, + position: i, }); } } diff --git a/src/routes/(api)/api/v4/pages/[page_path]/+server.ts b/src/routes/(api)/api/v4/pages/[page_path]/+server.ts index b310d1c2..7bac48d9 100644 --- a/src/routes/(api)/api/v4/pages/[page_path]/+server.ts +++ b/src/routes/(api)/api/v4/pages/[page_path]/+server.ts @@ -107,7 +107,7 @@ async function formatPageResponse(page: PageRecord): Promise { page_subheader: page.page_subheader, page_logo: page.page_logo, page_settings: pageSettings, - monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag })), + monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag, position: pm.position })), created_at: formatDateToISO(page.created_at), updated_at: formatDateToISO(page.updated_at), }; @@ -291,11 +291,12 @@ export const PATCH: RequestHandler = async ({ locals, request }) => { await db.deletePageMonitorsByPageId(page.id); // Add new monitors - for (const monitorTag of body.monitors) { + for (let i = 0; i < body.monitors.length; i++) { await db.addMonitorToPage({ page_id: page.id, - monitor_tag: monitorTag, + monitor_tag: body.monitors[i], monitor_settings_json: null, + position: i, }); } } diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index 3a1da271..97de0358 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -61,6 +61,7 @@ import { AddMonitorToPage, RemoveMonitorFromPage, GetPageMonitors, + ReorderPageMonitors, } from "$lib/server/controllers/pagesController.js"; import { CreateMaintenance, @@ -409,6 +410,10 @@ export async function POST({ request, cookies }) { AdminEditorCan(userDB.role); await RemoveMonitorFromPage(data.page_id, data.monitor_tag); resp = { success: true }; + } else if (action == "reorderPageMonitors") { + AdminEditorCan(userDB.role); + await ReorderPageMonitors(data.page_id, data.monitor_tags); + resp = { success: true }; } // ============ Maintenance Actions ============ else if (action == "getMaintenances") { diff --git a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte index 4c531cfc..5fa564c5 100644 --- a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte +++ b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte @@ -14,6 +14,8 @@ import SaveIcon from "@lucide/svelte/icons/save"; import PlusIcon from "@lucide/svelte/icons/plus"; import XIcon from "@lucide/svelte/icons/x"; + import ArrowUpIcon from "@lucide/svelte/icons/arrow-up"; + import ArrowDownIcon from "@lucide/svelte/icons/arrow-down"; import UploadIcon from "@lucide/svelte/icons/upload"; import ImageIcon from "@lucide/svelte/icons/image"; import TrashIcon from "@lucide/svelte/icons/trash"; @@ -68,6 +70,7 @@ let selectedMonitors = $state([]); let addingMonitor = $state(false); let removingMonitor = $state(null); + let reordering = $state(false); // Delete state let deleteConfirmText = $state(""); @@ -231,7 +234,7 @@ toast.error(result.error); } else { toast.success("Monitor added to page"); - selectedMonitors = [selectedMonitorTag, ...selectedMonitors.filter((tag) => tag !== selectedMonitorTag)]; + selectedMonitors = [...selectedMonitors, selectedMonitorTag]; selectedMonitorTag = ""; } } catch (e) { @@ -303,6 +306,39 @@ // Get available monitors (not already on the current page) const availableMonitors = $derived(monitors.filter((m) => !selectedMonitors.includes(m.tag))); + async function moveMonitor(index: number, direction: "up" | "down") { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= selectedMonitors.length) return; + + const updated = [...selectedMonitors]; + [updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]; + selectedMonitors = updated; + + if (!currentPage) return; + reordering = true; + try { + const response = await fetch(clientResolver(resolve, "/manage/api"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "reorderPageMonitors", + data: { + page_id: currentPage.id, + monitor_tags: selectedMonitors + } + }) + }); + const result = await response.json(); + if (result.error) { + toast.error(result.error); + } + } catch (e) { + toast.error("Failed to reorder monitors"); + } finally { + reordering = false; + } + } + // Image upload functions async function handleLogoUpload(event: Event): Promise { const input = event.target as HTMLInputElement; @@ -614,25 +650,43 @@ {#if selectedMonitors.length > 0}
- {#each selectedMonitors as monitorTag (monitorTag)} + {#each selectedMonitors as monitorTag, i (monitorTag)} {@const monitor = monitors.find((m) => m.tag === monitorTag)}

{monitor?.name || monitorTag}

{monitorTag}

- +
+ + + +
{/each}