Merge pull request #16 from archtika/devel

Refactor web code, database migrations and set security headers
This commit is contained in:
Thilo Hohlt
2024-09-28 15:58:03 +02:00
committed by GitHub
52 changed files with 1671 additions and 1821 deletions

8
flake.lock generated
View File

@@ -2,16 +2,16 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1721497942, "lastModified": 1726463316,
"narHash": "sha256-EDPL9qJfklXoowl3nEBmjDIqcvXKUZInt5n6CCc1Hn4=", "narHash": "sha256-gI9kkaH0ZjakJOKrdjaI/VbaMEo9qBbSUl93DnU7f4c=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d43f0636fc9492e83be8bbb41f9595d7a87106b8", "rev": "99dc8785f6a0adac95f5e2ab05cc2e1bf666d172",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixpkgs-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View File

@@ -1,6 +1,6 @@
{ {
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
}; };
outputs = outputs =

View File

@@ -15,5 +15,6 @@
acmeEmail = "thilo.hohlt@tutanota.com"; acmeEmail = "thilo.hohlt@tutanota.com";
dnsProvider = "porkbun"; dnsProvider = "porkbun";
dnsEnvironmentFile = /var/lib/porkbun.env; dnsEnvironmentFile = /var/lib/porkbun.env;
disableRegistration = true;
}; };
} }

View File

@@ -75,6 +75,12 @@ in
default = null; default = null;
description = "API secrets for the DNS-01 challenge (required for wildcard domains)."; description = "API secrets for the DNS-01 challenge (required for wildcard domains).";
}; };
disableRegistration = mkOption {
type = types.bool;
default = false;
description = "By default any user can create an account. That behavior can be disabled by using this option.";
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@@ -108,7 +114,7 @@ in
${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up ${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up
PGRST_ADMIN_SERVER_PORT=${toString cfg.apiAdminPort} PGRST_SERVER_PORT=${toString cfg.apiPort} PGRST_DB_SCHEMAS="api" PGRST_DB_ANON_ROLE="anon" PGRST_OPENAPI_MODE="ignore-privileges" PGRST_DB_URI="postgres://authenticator@localhost:5432/${cfg.databaseName}" PGRST_JWT_SECRET="$JWT_SECRET" ${pkgs.postgrest}/bin/postgrest PGRST_SERVER_CORS_ALLOWED_ORIGINS="https://${cfg.domain}" PGRST_ADMIN_SERVER_PORT=${toString cfg.apiAdminPort} PGRST_SERVER_PORT=${toString cfg.apiPort} PGRST_DB_SCHEMAS="api" PGRST_DB_ANON_ROLE="anon" PGRST_OPENAPI_MODE="ignore-privileges" PGRST_DB_URI="postgres://authenticator@localhost:5432/${cfg.databaseName}" PGRST_JWT_SECRET="$JWT_SECRET" ${pkgs.postgrest}/bin/postgrest
''; '';
}; };
@@ -125,7 +131,7 @@ in
}; };
script = '' script = ''
BODY_SIZE_LIMIT=Infinity ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app REGISTRATION_IS_DISABLED=${toString cfg.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app
''; '';
}; };
@@ -148,6 +154,20 @@ in
enable = true; enable = true;
recommendedProxySettings = true; recommendedProxySettings = true;
recommendedTlsSettings = true; recommendedTlsSettings = true;
recommendedZstdSettings = true;
recommendedOptimisation = true;
appendHttpConfig = ''
limit_req_zone $binary_remote_addr zone=requestLimit:10m rate=5r/s;
limit_req_status 429;
limit_req zone=requestLimit burst=20 nodelay;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(),autoplay=(),camera=(),cross-origin-isolated=(),display-capture=(),encrypted-media=(),fullscreen=(self),geolocation=(),gyroscope=(),keyboard-map=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(self),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),xr-spatial-tracking=(),clipboard-read=(self),clipboard-write=(self),gamepad=(),hid=(),idle-detection=(),interest-cohort=(),serial=(),unload=()" always;
'';
virtualHosts = { virtualHosts = {
"${cfg.domain}" = { "${cfg.domain}" = {
@@ -166,13 +186,16 @@ in
proxyPass = "http://localhost:${toString cfg.apiPort}/"; proxyPass = "http://localhost:${toString cfg.apiPort}/";
extraConfig = '' extraConfig = ''
default_type application/json; default_type application/json;
proxy_set_header Connection ""; '';
proxy_http_version 1.1; };
"/api/rpc/register" = mkIf cfg.disableRegistration {
extraConfig = ''
deny all;
''; '';
}; };
}; };
}; };
"~^(?<subdomain>.+)\\.${lib.strings.escapeRegex cfg.domain}$" = { "~^(?<subdomain>.+)\\.${cfg.domain}$" = {
useACMEHost = cfg.domain; useACMEHost = cfg.domain;
forceSSL = true; forceSSL = true;
locations = { locations = {

View File

@@ -25,7 +25,8 @@ CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (), id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3), username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
password_hash CHAR(60) NOT NULL, password_hash CHAR(60) NOT NULL,
role NAME NOT NULL DEFAULT 'authenticated_user' role NAME NOT NULL DEFAULT 'authenticated_user',
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP()
); );
CREATE TABLE internal.website ( CREATE TABLE internal.website (
@@ -33,8 +34,8 @@ CREATE TABLE internal.website (
user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID, user_id UUID REFERENCES internal.user (id) ON DELETE CASCADE NOT NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL, content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''), title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
is_published BOOLEAN NOT NULL DEFAULT FALSE, is_published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL, last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
@@ -52,8 +53,10 @@ CREATE TABLE internal.media (
CREATE TABLE internal.settings ( CREATE TABLE internal.settings (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE, website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
accent_color_light_theme CHAR(7) CHECK (accent_color_light_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#a5d8ff', accent_color_dark_theme CHAR(7) CHECK (accent_color_light_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#a5d8ff',
accent_color_dark_theme CHAR(7) CHECK (accent_color_dark_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#114678', accent_color_light_theme CHAR(7) CHECK (accent_color_dark_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#114678',
background_color_dark_theme CHAR(7) CHECK (accent_color_light_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#262626',
background_color_light_theme CHAR(7) CHECK (accent_color_dark_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#ffffff',
favicon_image UUID REFERENCES internal.media (id) ON DELETE SET NULL, favicon_image UUID REFERENCES internal.media (id) ON DELETE SET NULL,
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
@@ -82,6 +85,7 @@ CREATE TABLE internal.docs_category (
user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID, user_id UUID REFERENCES internal.user (id) ON DELETE SET NULL DEFAULT (CURRENT_SETTING('request.jwt.claims', TRUE)::JSON ->> 'user_id') ::UUID,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''), category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
category_weight INTEGER CHECK (category_weight >= 0) NOT NULL, category_weight INTEGER CHECK (category_weight >= 0) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL, last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
UNIQUE (website_id, category_name), UNIQUE (website_id, category_name),
@@ -117,6 +121,7 @@ CREATE TABLE internal.footer (
CREATE TABLE internal.legal_information ( CREATE TABLE internal.legal_information (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE, website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''), main_content TEXT NOT NULL CHECK (TRIM(main_content) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(), last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
); );

View File

@@ -127,39 +127,39 @@ GRANT SELECT ON api.account TO authenticated_user;
GRANT SELECT ON api.user TO authenticated_user; GRANT SELECT ON api.user TO authenticated_user;
GRANT SELECT, UPDATE, DELETE ON internal.website TO authenticated_user; GRANT SELECT, UPDATE (title, is_published), DELETE ON internal.website TO authenticated_user;
GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user; GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user;
GRANT SELECT, UPDATE ON internal.settings TO authenticated_user; GRANT SELECT, UPDATE (accent_color_dark_theme, accent_color_light_theme, background_color_dark_theme, background_color_light_theme, favicon_image) ON internal.settings TO authenticated_user;
GRANT SELECT, UPDATE ON api.settings TO authenticated_user; GRANT SELECT, UPDATE ON api.settings TO authenticated_user;
GRANT SELECT, UPDATE ON internal.header TO authenticated_user; GRANT SELECT, UPDATE (logo_type, logo_text, logo_image) ON internal.header TO authenticated_user;
GRANT SELECT, UPDATE ON api.header TO authenticated_user; GRANT SELECT, UPDATE ON api.header TO authenticated_user;
GRANT SELECT, UPDATE ON internal.home TO authenticated_user; GRANT SELECT, UPDATE (main_content) ON internal.home TO authenticated_user;
GRANT SELECT, UPDATE ON api.home TO authenticated_user; GRANT SELECT, UPDATE ON api.home TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.article TO authenticated_user; GRANT SELECT, INSERT (website_id, title, meta_description, meta_author, cover_image, publication_date, main_content, category, article_weight), UPDATE (title, meta_description, meta_author, cover_image, publication_date, main_content, category, article_weight), DELETE ON internal.article TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user; GRANT SELECT, INSERT, UPDATE, DELETE ON api.article TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.docs_category TO authenticated_user; GRANT SELECT, INSERT (website_id, category_name, category_weight), UPDATE (category_name, category_weight), DELETE ON internal.docs_category TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user; GRANT SELECT, INSERT, UPDATE, DELETE ON api.docs_category TO authenticated_user;
GRANT SELECT, UPDATE ON internal.footer TO authenticated_user; GRANT SELECT, UPDATE (additional_text) ON internal.footer TO authenticated_user;
GRANT SELECT, UPDATE ON api.footer TO authenticated_user; GRANT SELECT, UPDATE ON api.footer TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.legal_information TO authenticated_user; GRANT SELECT, INSERT (website_id, main_content), UPDATE (website_id, main_content), DELETE ON internal.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user; GRANT SELECT, INSERT, UPDATE, DELETE ON api.legal_information TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON internal.collab TO authenticated_user; GRANT SELECT, INSERT (website_id, user_id, permission_level), UPDATE (permission_level), DELETE ON internal.collab TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user; GRANT SELECT, INSERT, UPDATE, DELETE ON api.collab TO authenticated_user;

View File

@@ -30,7 +30,8 @@ BEGIN
RETURN COALESCE(NEW, OLD); RETURN COALESCE(NEW, OLD);
END; END;
$$ $$
LANGUAGE plpgsql; LANGUAGE plpgsql
SECURITY DEFINER;
CREATE TRIGGER update_website_last_modified CREATE TRIGGER update_website_last_modified
BEFORE UPDATE ON internal.website BEFORE UPDATE ON internal.website
@@ -68,7 +69,7 @@ CREATE TRIGGER update_footer_last_modified
EXECUTE FUNCTION internal.update_last_modified (); EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER update_legal_information_last_modified CREATE TRIGGER update_legal_information_last_modified
BEFORE INSERT OR DELETE ON internal.legal_information BEFORE INSERT OR UPDATE OR DELETE ON internal.legal_information
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified (); EXECUTE FUNCTION internal.update_last_modified ();

View File

@@ -8,7 +8,7 @@ DECLARE
_website_id UUID := (_headers ->> 'x-website-id')::UUID; _website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype'; _mimetype TEXT := _headers ->> 'x-mimetype';
_original_filename TEXT := _headers ->> 'x-original-filename'; _original_filename TEXT := _headers ->> 'x-original-filename';
_allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp']; _allowed_mimetypes TEXT[] := ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'image/svg+xml'];
_max_file_size INT := 5 * 1024 * 1024; _max_file_size INT := 5 * 1024 * 1024;
_has_access BOOLEAN; _has_access BOOLEAN;
BEGIN BEGIN

View File

@@ -0,0 +1,57 @@
-- migrate:up
CREATE TABLE internal.domain_prefix (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
prefix VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(prefix) >= 3 AND prefix ~ '^[a-z]+(-[a-z]+)*$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
last_modified_by UUID REFERENCES internal.user (id) ON DELETE SET NULL
);
CREATE VIEW api.domain_prefix WITH ( security_invoker = ON
) AS
SELECT
*
FROM
internal.domain_prefix;
GRANT SELECT, INSERT (website_id, prefix), UPDATE (website_id, prefix), DELETE ON internal.domain_prefix TO authenticated_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON api.domain_prefix TO authenticated_user;
ALTER TABLE internal.domain_prefix ENABLE ROW LEVEL SECURITY;
CREATE POLICY view_domain_prefix ON internal.domain_prefix
FOR SELECT
USING (internal.user_has_website_access (website_id, 10));
CREATE POLICY update_domain_prefix ON internal.domain_prefix
FOR UPDATE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY delete_domain_prefix ON internal.domain_prefix
FOR DELETE
USING (internal.user_has_website_access (website_id, 30));
CREATE POLICY insert_domain_prefix ON internal.domain_prefix
FOR INSERT
WITH CHECK (internal.user_has_website_access (website_id, 30));
CREATE TRIGGER update_domain_prefix_last_modified
BEFORE INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.update_last_modified ();
CREATE TRIGGER domain_prefix_track_changes
AFTER INSERT OR UPDATE OR DELETE ON internal.domain_prefix
FOR EACH ROW
EXECUTE FUNCTION internal.track_changes ();
-- migrate:down
DROP TRIGGER domain_prefix_track_changes ON internal.domain_prefix;
DROP TRIGGER update_domain_prefix_last_modified ON internal.domain_prefix;
DROP VIEW api.domain_prefix;
DROP TABLE internal.domain_prefix;

File diff suppressed because it is too large Load Diff

View File

@@ -14,34 +14,34 @@
"gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal" "gents": "pg-to-ts generate -c postgres://postgres@localhost:15432/archtika -o src/lib/db-schema.ts -s internal"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.40.0", "@playwright/test": "1.46.0",
"@sveltejs/adapter-auto": "3.2.4", "@sveltejs/adapter-auto": "3.2.5",
"@sveltejs/adapter-node": "5.2.2", "@sveltejs/adapter-node": "5.2.3",
"@sveltejs/kit": "2.5.22", "@sveltejs/kit": "2.5.28",
"@sveltejs/vite-plugin-svelte": "3.1.1", "@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3", "@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3", "@types/eslint-config-prettier": "6.11.3",
"@types/node": "22.2.0", "@types/node": "22.5.5",
"eslint": "9.10.0", "eslint": "9.10.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.43.0", "eslint-plugin-svelte": "2.44.0",
"globals": "15.9.0", "globals": "15.9.0",
"pg-to-ts": "4.1.1", "pg-to-ts": "4.1.1",
"prettier": "3.3.3", "prettier": "3.3.3",
"prettier-plugin-svelte": "3.2.6", "prettier-plugin-svelte": "3.2.6",
"svelte": "5.0.0-next.220", "svelte": "5.0.0-next.253",
"svelte-check": "3.8.5", "svelte-check": "4.0.2",
"typescript": "5.5.4", "typescript": "5.6.2",
"typescript-eslint": "8.4.0", "typescript-eslint": "8.6.0",
"vite": "5.4.0" "vite": "5.4.6"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"fast-diff": "1.3.0", "fast-diff": "1.3.0",
"highlight.js": "11.10.0", "highlight.js": "11.10.0",
"isomorphic-dompurify": "2.14.0", "isomorphic-dompurify": "2.15.0",
"marked": "14.0.0", "marked": "14.1.2",
"marked-highlight": "2.1.4" "marked-highlight": "2.1.4"
} }
} }

View File

@@ -1,32 +1,36 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { User } from "$lib/db-schema";
export const handle = async ({ event, resolve }) => { export const handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith("/api/")) { if (!event.url.pathname.startsWith("/api/")) {
const userData = await event.fetch(`${API_BASE_PREFIX}/account`, { const userData = await apiRequest(event.fetch, `${API_BASE_PREFIX}/account`, "GET", {
method: "GET",
headers: { headers: {
"Content-Type": "application/json", Accept: "application/vnd.pgrst.object+json",
Authorization: `Bearer ${event.cookies.get("session_token")}`, Authorization: `Bearer ${event.cookies.get("session_token")}`
Accept: "application/vnd.pgrst.object+json" },
} returnData: true
}); });
if (!userData.ok && !["/login", "/register"].includes(event.url.pathname)) { if (!userData.success && !["/login", "/register"].includes(event.url.pathname)) {
throw redirect(303, "/login"); throw redirect(303, "/login");
} }
if (userData.ok) { if (userData.success) {
if (["/login", "/register"].includes(event.url.pathname)) { if (["/login", "/register"].includes(event.url.pathname)) {
throw redirect(303, "/"); throw redirect(303, "/");
} }
const user: User = await userData.json(); event.locals.user = userData.data;
event.locals.user = user;
} }
} }
return await resolve(event); return await resolve(event);
}; };
export const handleFetch = async ({ event, request, fetch }) => {
if (event.locals.user) {
request.headers.set("Authorization", `Bearer ${event.cookies.get("session_token")}`);
}
return fetch(request);
};

View File

@@ -10,7 +10,7 @@
.spinner { .spinner {
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: var(--bg-blurred);
z-index: 40; z-index: 40;
} }
@@ -27,6 +27,6 @@
border: var(--border-primary); border: var(--border-primary);
border-width: 0.125rem; border-width: 0.125rem;
border-block-start-color: var(--color-accent); border-block-start-color: var(--color-accent);
animation: spinner 0.6s linear infinite; animation: spinner 500ms linear infinite;
} }
</style> </style>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { deserialize, applyAction } from "$app/forms";
import { textareaScrollTop, previewContent } from "$lib/runes.svelte";
const {
apiPrefix,
label,
name,
content
}: { apiPrefix: string; label: string; name: string; content: string } = $props();
let mainContentTextarea: HTMLTextAreaElement;
const updateScrollPercentage = () => {
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
textareaScrollTop.value = (scrollTop / (scrollHeight - clientHeight)) * 100;
};
const handleImagePaste = async (event: ClipboardEvent) => {
const clipboardItems = Array.from(event.clipboardData?.items ?? []);
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
if (!file) return null;
event.preventDefault();
const fileObject = file.getAsFile();
if (!fileObject) return;
const formData = new FormData();
formData.append("file", fileObject);
const request = await fetch("?/pasteImage", {
method: "POST",
body: formData
});
const result = deserialize(await request.clone().text());
applyAction(result);
const response = await request.json();
if (JSON.parse(response.data)[1]) {
const fileId = JSON.parse(response.data)[4];
const fileUrl = `${apiPrefix}/rpc/retrieve_file?id=${fileId}`;
const target = event.target as HTMLTextAreaElement;
const newContent =
target.value.slice(0, target.selectionStart) +
`![](${fileUrl})` +
target.value.slice(target.selectionStart);
previewContent.value = newContent;
} else {
return;
}
};
</script>
<label>
{label}:
<textarea
{name}
rows="20"
bind:value={previewContent.value}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
onpaste={handleImagePaste}
required>{content}</textarea
>
</label>

View File

@@ -39,7 +39,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10; z-index: 10;
background-color: rgba(0, 0, 0, 0.5); background-color: var(--bg-blurred);
} }
.modal__content { .modal__content {

View File

@@ -2,30 +2,27 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { md } from "$lib/utils"; import { md } from "$lib/utils";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { previewContent, textareaScrollTop } from "$lib/runes.svelte";
const { const {
id, id,
contentType, contentType,
title, title,
children, children,
fullPreview = false, fullPreview = false
previewContent,
previewScrollTop = 0
}: { }: {
id: string; id: string;
contentType: string; contentType: string;
title: string; title: string;
children: Snippet; children: Snippet;
fullPreview?: boolean; fullPreview?: boolean;
previewContent: string;
previewScrollTop?: number;
} = $props(); } = $props();
let previewElement: HTMLDivElement; let previewElement: HTMLDivElement;
$effect(() => { $effect(() => {
const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight; const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight;
previewElement.scrollTop = (previewScrollTop / 100) * scrollHeight; previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
}); });
</script> </script>
@@ -66,9 +63,12 @@
<div class="preview" bind:this={previewElement}> <div class="preview" bind:this={previewElement}>
{#if fullPreview} {#if fullPreview}
<iframe src={previewContent} title="Preview"></iframe> <iframe src={previewContent.value} title="Preview"></iframe>
{:else} {:else}
{@html md(previewContent, Object.keys($page.params).length > 1 ? true : false)} {@html md(
previewContent.value || "Write some markdown content to see a live preview here",
Object.keys($page.params).length > 1 ? true : false
)}
{/if} {/if}
</div> </div>

View File

@@ -5,7 +5,7 @@
* AUTO-GENERATED FILE - DO NOT EDIT! * AUTO-GENERATED FILE - DO NOT EDIT!
* *
* This file was automatically generated by pg-to-ts v.4.1.1 * This file was automatically generated by pg-to-ts v.4.1.1
* $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal * $ pg-to-ts generate -c postgres://username:password@localhost:15432/archtika -t article -t change_log -t collab -t docs_category -t domain_prefix -t footer -t header -t home -t legal_information -t media -t settings -t user -t website -s internal
* *
*/ */
@@ -169,6 +169,7 @@ export interface DocsCategory {
user_id: string | null; user_id: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
created_at: Date;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
} }
@@ -178,6 +179,7 @@ export interface DocsCategoryInput {
user_id?: string | null; user_id?: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
created_at?: Date;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
@@ -189,6 +191,7 @@ const docs_category = {
"user_id", "user_id",
"category_name", "category_name",
"category_weight", "category_weight",
"created_at",
"last_modified_at", "last_modified_at",
"last_modified_by" "last_modified_by"
], ],
@@ -203,6 +206,34 @@ const docs_category = {
$input: null as unknown as DocsCategoryInput $input: null as unknown as DocsCategoryInput
} as const; } as const;
// Table domain_prefix
export interface DomainPrefix {
website_id: string;
prefix: string;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface DomainPrefixInput {
website_id: string;
prefix: string;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const domain_prefix = {
tableName: "domain_prefix",
columns: ["website_id", "prefix", "created_at", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "prefix"],
primaryKey: "website_id",
foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
},
$type: null as unknown as DomainPrefix,
$input: null as unknown as DomainPrefixInput
} as const;
// Table footer // Table footer
export interface Footer { export interface Footer {
website_id: string; website_id: string;
@@ -297,18 +328,20 @@ const home = {
export interface LegalInformation { export interface LegalInformation {
website_id: string; website_id: string;
main_content: string; main_content: string;
created_at: Date;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface LegalInformationInput { export interface LegalInformationInput {
website_id: string; website_id: string;
main_content: string; main_content: string;
created_at?: Date;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
} }
const legal_information = { const legal_information = {
tableName: "legal_information", tableName: "legal_information",
columns: ["website_id", "main_content", "last_modified_at", "last_modified_by"], columns: ["website_id", "main_content", "created_at", "last_modified_at", "last_modified_by"],
requiredForInsert: ["website_id", "main_content"], requiredForInsert: ["website_id", "main_content"],
primaryKey: "website_id", primaryKey: "website_id",
foreignKeys: { foreignKeys: {
@@ -354,16 +387,20 @@ const media = {
// Table settings // Table settings
export interface Settings { export interface Settings {
website_id: string; website_id: string;
accent_color_light_theme: string;
accent_color_dark_theme: string; accent_color_dark_theme: string;
accent_color_light_theme: string;
background_color_dark_theme: string;
background_color_light_theme: string;
favicon_image: string | null; favicon_image: string | null;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
} }
export interface SettingsInput { export interface SettingsInput {
website_id: string; website_id: string;
accent_color_light_theme?: string;
accent_color_dark_theme?: string; accent_color_dark_theme?: string;
accent_color_light_theme?: string;
background_color_dark_theme?: string;
background_color_light_theme?: string;
favicon_image?: string | null; favicon_image?: string | null;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
@@ -372,8 +409,10 @@ const settings = {
tableName: "settings", tableName: "settings",
columns: [ columns: [
"website_id", "website_id",
"accent_color_light_theme",
"accent_color_dark_theme", "accent_color_dark_theme",
"accent_color_light_theme",
"background_color_dark_theme",
"background_color_light_theme",
"favicon_image", "favicon_image",
"last_modified_at", "last_modified_at",
"last_modified_by" "last_modified_by"
@@ -395,16 +434,18 @@ export interface User {
username: string; username: string;
password_hash: string; password_hash: string;
role: string; role: string;
created_at: Date;
} }
export interface UserInput { export interface UserInput {
id?: string; id?: string;
username: string; username: string;
password_hash: string; password_hash: string;
role?: string; role?: string;
created_at?: Date;
} }
const user = { const user = {
tableName: "user", tableName: "user",
columns: ["id", "username", "password_hash", "role"], columns: ["id", "username", "password_hash", "role", "created_at"],
requiredForInsert: ["username", "password_hash"], requiredForInsert: ["username", "password_hash"],
primaryKey: "id", primaryKey: "id",
foreignKeys: {}, foreignKeys: {},
@@ -418,8 +459,8 @@ export interface Website {
user_id: string; user_id: string;
content_type: string; content_type: string;
title: string; title: string;
created_at: Date;
is_published: boolean; is_published: boolean;
created_at: Date;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
title_search: any | null; title_search: any | null;
@@ -429,8 +470,8 @@ export interface WebsiteInput {
user_id?: string; user_id?: string;
content_type: string; content_type: string;
title: string; title: string;
created_at?: Date;
is_published?: boolean; is_published?: boolean;
created_at?: Date;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
title_search?: any | null; title_search?: any | null;
@@ -442,8 +483,8 @@ const website = {
"user_id", "user_id",
"content_type", "content_type",
"title", "title",
"created_at",
"is_published", "is_published",
"created_at",
"last_modified_at", "last_modified_at",
"last_modified_by", "last_modified_by",
"title_search" "title_search"
@@ -475,6 +516,10 @@ export interface TableTypes {
select: DocsCategory; select: DocsCategory;
input: DocsCategoryInput; input: DocsCategoryInput;
}; };
domain_prefix: {
select: DomainPrefix;
input: DomainPrefixInput;
};
footer: { footer: {
select: Footer; select: Footer;
input: FooterInput; input: FooterInput;
@@ -514,6 +559,7 @@ export const tables = {
change_log, change_log,
collab, collab,
docs_category, docs_category,
domain_prefix,
footer, footer,
header, header,
home, home,

View File

@@ -0,0 +1,30 @@
let sendingState = $state(false);
let previewContentState = $state("");
let textareaScrollTopState = $state(0);
export const sending = {
get value() {
return sendingState;
},
set value(val) {
sendingState = val;
}
};
export const previewContent = {
get value() {
return previewContentState;
},
set value(val) {
previewContentState = val;
}
};
export const textareaScrollTop = {
get value() {
return textareaScrollTopState;
},
set value(val) {
textareaScrollTopState = val;
}
};

View File

@@ -3,3 +3,54 @@ import { dev } from "$app/environment";
export const API_BASE_PREFIX = dev export const API_BASE_PREFIX = dev
? "http://localhost:3000" ? "http://localhost:3000"
: `${process.env.ORIGIN ? `${process.env.ORIGIN}/api` : "http://localhost:3000"}`; : `${process.env.ORIGIN ? `${process.env.ORIGIN}/api` : "http://localhost:3000"}`;
export const REGISTRATION_IS_DISABLED = dev
? false
: process.env.REGISTRATION_IS_DISABLED
? JSON.parse(process.env.REGISTRATION_IS_DISABLED)
: false;
export const apiRequest = async (
customFetch: typeof fetch,
url: string,
method: "HEAD" | "GET" | "POST" | "PATCH" | "DELETE",
options: {
headers?: Record<string, string>;
body?: any;
successMessage?: string;
returnData?: boolean;
} = {
headers: {},
body: undefined,
successMessage: "Operation was successful",
returnData: false
}
) => {
const headers = {
"Content-Type": "application/json",
...options.headers
};
const response = await customFetch(url, {
method,
headers,
...(!["HEAD", "GET", "DELETE"].includes(method) && {
body: options.body instanceof ArrayBuffer ? options.body : JSON.stringify(options.body)
})
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, message: errorData.message };
}
if (options.returnData) {
return {
success: true,
message: options.successMessage,
data: method === "HEAD" ? response : await response.json()
};
}
return { success: true, message: options.successMessage };
};

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { WebsiteOverview } from "../../utils"; import { type WebsiteOverview, md } from "../../utils";
const { const {
websiteOverview, websiteOverview,
@@ -10,7 +10,7 @@
<footer> <footer>
<div class="container"> <div class="container">
<small> <small>
{@html websiteOverview.footer.additional_text.replace( {@html md(websiteOverview.footer.additional_text, false).replace(
"!!legal", "!!legal",
`<a href="${isIndexPage ? "./legal-information" : "../legal-information"}">Legal information</a>` `<a href="${isIndexPage ? "./legal-information" : "../legal-information"}">Legal information</a>`
)} )}

View File

@@ -17,7 +17,6 @@
</script> </script>
<svelte:head> <svelte:head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title> <title>{title}</title>
@@ -29,5 +28,4 @@
href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}" href="{apiUrl}/rpc/retrieve_file?id={websiteOverview.settings.favicon_image}"
/> />
{/if} {/if}
</head>
</svelte:head> </svelte:head>

View File

@@ -12,10 +12,20 @@ import type {
Footer, Footer,
Article, Article,
DocsCategory, DocsCategory,
LegalInformation LegalInformation,
DomainPrefix
} from "$lib/db-schema"; } from "$lib/db-schema";
import type { SubmitFunction } from "@sveltejs/kit";
import { sending } from "./runes.svelte";
export const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]; export const ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"image/svg+xml"
];
export const slugify = (string: string) => { export const slugify = (string: string) => {
return string return string
@@ -24,8 +34,8 @@ export const slugify = (string: string) => {
.toLowerCase() // Convert to lowercase .toLowerCase() // Convert to lowercase
.trim() // Trim leading and trailing whitespace .trim() // Trim leading and trailing whitespace
.replace(/\s+/g, "-") // Replace spaces with hyphens .replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/[^\w\-]+/g, "") // Remove non-word characters (except hyphens) .replace(/[^\w-]+/g, "") // Remove non-word characters (except hyphens)
.replace(/\-\-+/g, "-") // Replace multiple hyphens with single hyphen .replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-+/, "") // Remove leading hyphens .replace(/^-+/, "") // Remove leading hyphens
.replace(/-+$/, ""); // Remove trailing hyphens .replace(/-+$/, ""); // Remove trailing hyphens
}; };
@@ -51,8 +61,8 @@ const createMarkdownParser = (showToc = true) => {
); );
const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => { const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => {
let headings: { text: string; level: number; id: string }[] = []; const headings: { text: string; level: number; id: string }[] = [];
let sectionStack: { level: number; id: string }[] = []; const sectionStack: { level: number; id: string }[] = [];
return { return {
renderer: { renderer: {
@@ -143,45 +153,59 @@ export const md = (markdownContent: string, showToc = true) => {
return html; return html;
}; };
export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: string) => { export const LOADING_DELAY = 500;
const clipboardItems = Array.from(event.clipboardData?.items ?? []); let loadingDelay: number;
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
if (!file) return null; export const enhanceForm = (options?: {
reset?: boolean;
closeModal?: boolean;
}): SubmitFunction => {
return () => {
loadingDelay = window.setTimeout(() => (sending.value = true), LOADING_DELAY);
event.preventDefault(); return async ({ update }) => {
await update({ reset: options?.reset ?? true });
const fileObject = file.getAsFile(); window.clearTimeout(loadingDelay);
if (options?.closeModal) {
if (!fileObject) return; window.location.hash = "!";
const formData = new FormData();
formData.append("file", fileObject);
const request = await fetch("?/pasteImage", {
method: "POST",
body: formData
});
const result = deserialize(await request.clone().text());
applyAction(result);
const response = await request.json();
if (JSON.parse(response.data)[1]) {
const fileId = JSON.parse(response.data)[3];
const fileUrl = `${API_BASE_PREFIX}/rpc/retrieve_file?id=${fileId}`;
const target = event.target as HTMLTextAreaElement;
const newContent =
target.value.slice(0, target.selectionStart) +
`![](${fileUrl})` +
target.value.slice(target.selectionStart);
return newContent;
} else {
return "";
} }
sending.value = false;
};
};
};
export const hexToHSL = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const l = (max + min) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d !== 0) {
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
}; };
export interface WebsiteOverview extends Website { export interface WebsiteOverview extends Website {
@@ -191,4 +215,5 @@ export interface WebsiteOverview extends Website {
footer: Footer; footer: Footer;
article: (Article & { docs_category: DocsCategory | null })[]; article: (Article & { docs_category: DocsCategory | null })[];
legal_information?: LegalInformation; legal_information?: LegalInformation;
domain_prefix?: DomainPrefix;
} }

View File

@@ -1,26 +1,24 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, cookies, fetch }) => { default: async ({ request, cookies, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/login`, { const response = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/login`, "POST", {
method: "POST", body: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: data.get("username"), username: data.get("username"),
pass: data.get("password") pass: data.get("password")
}) },
returnData: true,
successMessage: "Successfully logged in, you can refresh the page"
}); });
const response = await res.json(); if (!response.success) {
return response;
if (!res.ok) {
return { success: false, message: response.message };
} }
cookies.set("session_token", response.token, { path: "/" }); cookies.set("session_token", response.data.token, { path: "/" });
return { success: true, message: "Successfully logged in" }; return response;
} }
}; };

View File

@@ -3,28 +3,19 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData } from "./$types"; import type { ActionData } from "./$types";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { sending } from "$lib/runes.svelte";
import { enhanceForm } from "$lib/utils";
const { form }: { form: ActionData } = $props(); const { form }: { form: ActionData } = $props();
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
<form <form method="POST" use:enhance={enhanceForm()}>
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<label> <label>
Username: Username:
<input type="text" name="username" required /> <input type="text" name="username" required />

View File

@@ -1,25 +1,22 @@
import type { Actions } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, REGISTRATION_IS_DISABLED, apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async () => {
return {
REGISTRATION_IS_DISABLED
};
};
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/register`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/register`, "POST", {
method: "POST", body: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: data.get("username"), username: data.get("username"),
pass: data.get("password") pass: data.get("password")
}) },
successMessage: "Successfully registered, you can now login"
}); });
const response = await res.json();
if (!res.ok) {
return { success: false, message: response.message };
}
return { success: true, message: "Successfully registered, you can now login" };
} }
}; };

View File

@@ -1,30 +1,40 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { sending } from "$lib/runes.svelte";
import { enhanceForm } from "$lib/utils";
const { form }: { form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
<form {#if data.REGISTRATION_IS_DISABLED}
method="POST" <p class="registration-disabled">
use:enhance={() => { <svg
sending = true; xmlns="http://www.w3.org/2000/svg"
return async ({ update }) => { viewBox="0 0 20 20"
await update(); fill="currentColor"
sending = false; width="20"
}; height="20"
}} color="var(--color-error)"
> >
<path
fill-rule="evenodd"
d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z"
clip-rule="evenodd"
></path>
</svg>
Account registration is disabled on this instance
</p>
{:else}
<form method="POST" use:enhance={enhanceForm()}>
<label> <label>
Username: Username:
<input type="text" name="username" minlength="3" maxlength="16" required /> <input type="text" name="username" minlength="3" maxlength="16" required />
@@ -36,3 +46,12 @@
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
{/if}
<style>
.registration-disabled {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@@ -1,10 +1,11 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { apiRequest } from "$lib/server/utils";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { Website, WebsiteInput } from "$lib/db-schema"; import type { Website } from "$lib/db-schema";
export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => { export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const searchQuery = url.searchParams.get("website_search_query"); const searchQuery = url.searchParams.get("website_search_query");
const filterBy = url.searchParams.get("website_filter"); const filterBy = url.searchParams.get("website_filter");
@@ -27,28 +28,22 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
const constructedFetchUrl = `${baseFetchUrl}&${params.toString()}`; const constructedFetchUrl = `${baseFetchUrl}&${params.toString()}`;
const totalWebsitesData = await fetch(baseFetchUrl, { const totalWebsites = await apiRequest(fetch, baseFetchUrl, "HEAD", {
method: "HEAD",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact" Prefer: "count=exact"
} },
returnData: true
}); });
const totalWebsiteCount = Number( const totalWebsiteCount = Number(
totalWebsitesData.headers.get("content-range")?.split("/").at(-1) totalWebsites.data.headers.get("content-range")?.split("/").at(-1)
); );
const websiteData = await fetch(constructedFetchUrl, { const websites: Website[] = (
method: "GET", await apiRequest(fetch, constructedFetchUrl, "GET", {
headers: { returnData: true
"Content-Type": "application/json", })
Authorization: `Bearer ${cookies.get("session_token")}` ).data;
}
});
const websites: Website[] = await websiteData.json();
return { return {
totalWebsiteCount, totalWebsiteCount,
@@ -57,70 +52,63 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
createWebsite: async ({ request, fetch, cookies }) => { createWebsite: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/create_website`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/create_website`, "POST", {
method: "POST", body: {
headers: { content_type: data.get("content-type"),
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
content_type: data.get("content-type") as string,
title: data.get("title") as string
} satisfies WebsiteInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully created website" };
},
updateWebsite: async ({ request, cookies, fetch }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
title: data.get("title") title: data.get("title")
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated website" };
}, },
deleteWebsite: async ({ request, cookies, fetch }) => { successMessage: "Successfully created website"
});
},
updateWebsite: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, "PATCH", {
method: "DELETE", body: {
headers: { title: data.get("title")
"Content-Type": "application/json", },
Authorization: `Bearer ${cookies.get("session_token")}` successMessage: "Successfully updated website"
}
}); });
},
deleteWebsite: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get("id");
if (!res.ok) { const oldDomainPrefix = (
const response = await res.json(); await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${id}`, "GET", {
return { success: false, message: response.message }; headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
})
).data;
const deleteWebsite = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${id}`,
"DELETE",
{
successMessage: "Successfully deleted website"
}
);
if (!deleteWebsite.success) {
return deleteWebsite;
} }
await rm(join("/", "var", "www", "archtika-websites", data.get("id") as string), { await rm(join("/", "var", "www", "archtika-websites", "previews", id as string), {
recursive: true, recursive: true,
force: true force: true
}); });
return { success: true, message: "Successfully deleted website" }; await rm(join("/", "var", "www", "archtika-websites", oldDomainPrefix?.prefix ?? id), {
recursive: true,
force: true
});
return deleteWebsite;
} }
}; };

View File

@@ -6,15 +6,15 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
const { form, data }: { form: ActionData; data: PageServerData } = $props(); const { form, data }: { form: ActionData; data: PageServerData } = $props();
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -26,18 +26,7 @@
<Modal id="create-website" text="Create website"> <Modal id="create-website" text="Create website">
<h3>Create website</h3> <h3>Create website</h3>
<form <form method="POST" action="?/createWebsite" use:enhance={enhanceForm({ closeModal: true })}>
method="POST"
action="?/createWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<label> <label>
Type: Type:
<select name="content-type"> <select name="content-type">
@@ -119,14 +108,7 @@
<form <form
method="POST" method="POST"
action="?/updateWebsite" action="?/updateWebsite"
use:enhance={() => { use:enhance={enhanceForm({ reset: false, closeModal: true })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="id" value={id} /> <input type="hidden" name="id" value={id} />
<label> <label>
@@ -154,14 +136,7 @@
<form <form
method="POST" method="POST"
action="?/deleteWebsite" action="?/deleteWebsite"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="id" value={id} /> <input type="hidden" name="id" value={id} />
@@ -179,7 +154,7 @@
.website-grid { .website-grid {
display: grid; display: grid;
gap: var(--space-s); gap: var(--space-s);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 35ch), 0.5fr));
margin-block-start: var(--space-xs); margin-block-start: var(--space-xs);
} }

View File

@@ -1,5 +1,5 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
return { return {
@@ -16,24 +16,18 @@ export const actions: Actions = {
deleteAccount: async ({ request, fetch, cookies }) => { deleteAccount: async ({ request, fetch, cookies }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/delete_account`, { const response = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/delete_account`, "POST", {
method: "POST", body: {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
pass: data.get("password") pass: data.get("password")
}) },
successMessage: "Successfully deleted account"
}); });
const response = await res.json(); if (!response.success) {
return response;
if (!res.ok) {
return { success: false, message: response.message };
} }
cookies.delete("session_token", { path: "/" }); cookies.delete("session_token", { path: "/" });
return { success: true, message: "Successfully deleted account" }; return response;
} }
}; };

View File

@@ -4,15 +4,15 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -38,17 +38,7 @@
<a href="#logout">Logout</a> <a href="#logout">Logout</a>
</h2> </h2>
<form <form method="POST" action="?/logout" use:enhance={enhanceForm()}>
method="POST"
action="?/logout"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<button type="submit">Logout</button> <button type="submit">Logout</button>
</form> </form>
</section> </section>
@@ -66,18 +56,7 @@
Deleting your account will irretrievably erase all data. Deleting your account will irretrievably erase all data.
</p> </p>
<form <form method="POST" action="?/deleteAccount" use:enhance={enhanceForm({ closeModal: true })}>
method="POST"
action="?/deleteAccount"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<label> <label>
Password: Password:
<input type="password" name="password" required /> <input type="password" name="password" required />
@@ -87,3 +66,9 @@
</form> </form>
</Modal> </Modal>
</section> </section>
<style>
form[action="?/logout"] > button {
max-inline-size: fit-content;
}
</style>

View File

@@ -1,36 +1,35 @@
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { Website, Home, User } from "$lib/db-schema"; import type { Website, Home, User } from "$lib/db-schema";
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => { export const load: LayoutServerLoad = async ({ params, fetch }) => {
const websiteData = await fetch( const websiteData = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`, `${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
"GET",
{ {
method: "GET",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} },
returnData: true
} }
); );
if (!websiteData.ok) { const website: Website & { user: { username: User["username"] } } = websiteData.data;
if (!websiteData.success) {
throw error(404, "Website not found"); throw error(404, "Website not found");
} }
const homeData = await fetch(`${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, { const home: Home = (
method: "GET", await apiRequest(fetch, `${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, "GET", {
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} },
}); returnData: true
})
const website: Website & { user: { username: User["username"] } } = await websiteData.json(); ).data;
const home: Home = await homeData.json();
return { return {
website, website,

View File

@@ -1,41 +1,40 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils";
import type { Settings, Header, Footer } from "$lib/db-schema"; import type { Settings, Header, Footer } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, cookies }) => { export const load: PageServerLoad = async ({ params, fetch }) => {
const globalSettingsData = await fetch( const globalSettings: Settings = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
"GET",
{ {
method: "GET",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
},
returnData: true
} }
} )
); ).data;
const headerData = await fetch(`${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, { const header: Header = (
method: "GET", await apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} },
}); returnData: true
})
).data;
const footerData = await fetch(`${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, { const footer: Footer = (
method: "GET", await apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} },
}); returnData: true
})
const globalSettings: Settings = await globalSettingsData.json(); ).data;
const header: Header = await headerData.json();
const footer: Footer = await footerData.json();
return { return {
globalSettings, globalSettings,
@@ -46,13 +45,12 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
updateGlobal: async ({ request, fetch, cookies, params }) => { updateGlobal: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const faviconFile = data.get("favicon") as File; const faviconFile = data.get("favicon") as File;
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json", Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId "X-Website-Id": params.websiteId
}; };
@@ -62,48 +60,38 @@ export const actions: Actions = {
headers["X-Original-Filename"] = faviconFile.name; headers["X-Original-Filename"] = faviconFile.name;
} }
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
method: "POST",
headers, headers,
body: faviconFile ? await faviconFile.arrayBuffer() : null body: faviconFile ? await faviconFile.arrayBuffer() : null,
returnData: true
}); });
const uploadedImage = await uploadedImageData.json(); if (!uploadedImage.success && (faviconFile?.size ?? 0 > 0)) {
return uploadedImage;
if (!uploadedImageData.ok && (faviconFile?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message };
} }
const res = await fetch(`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, { return await apiRequest(
method: "PATCH", fetch,
headers: { `${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
"Content-Type": "application/json", "PATCH",
Authorization: `Bearer ${cookies.get("session_token")}` {
}, body: {
body: JSON.stringify({
accent_color_light_theme: data.get("accent-color-light"),
accent_color_dark_theme: data.get("accent-color-dark"), accent_color_dark_theme: data.get("accent-color-dark"),
favicon_image: uploadedImage.file_id accent_color_light_theme: data.get("accent-color-light"),
}) background_color_dark_theme: data.get("background-color-dark"),
}); background_color_light_theme: data.get("background-color-light"),
favicon_image: uploadedImage.data?.file_id
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: "Successfully updated global settings"
};
}, },
updateHeader: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully updated global settings"
}
);
},
updateHeader: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const logoImage = data.get("logo-image") as File; const logoImage = data.get("logo-image") as File;
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json", Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId "X-Website-Id": params.websiteId
}; };
@@ -113,109 +101,75 @@ export const actions: Actions = {
headers["X-Original-Filename"] = logoImage.name; headers["X-Original-Filename"] = logoImage.name;
} }
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
method: "POST",
headers, headers,
body: logoImage ? await logoImage.arrayBuffer() : null body: logoImage ? await logoImage.arrayBuffer() : null,
returnData: true
}); });
const uploadedImage = await uploadedImageData.json(); if (!uploadedImage.success && (logoImage?.size ?? 0 > 0)) {
if (!uploadedImageData.ok && (logoImage?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message }; return { success: false, message: uploadedImage.message };
} }
const res = await fetch(`${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, { return await apiRequest(
method: "PATCH", fetch,
headers: { `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`,
"Content-Type": "application/json", "PATCH",
Authorization: `Bearer ${cookies.get("session_token")}` {
}, body: {
body: JSON.stringify({
logo_type: data.get("logo-type"), logo_type: data.get("logo-type"),
logo_text: data.get("logo-text"), logo_text: data.get("logo-text"),
logo_image: uploadedImage.file_id logo_image: uploadedImage.data?.file_id
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: "Successfully updated header"
};
}, },
updateHome: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully updated header"
}
);
},
updateHome: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, { return await apiRequest(
method: "PATCH", fetch,
headers: { `${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`,
"Content-Type": "application/json", "PATCH",
Authorization: `Bearer ${cookies.get("session_token")}` {
}, body: {
body: JSON.stringify({
main_content: data.get("main-content") main_content: data.get("main-content")
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated home" };
}, },
updateFooter: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully updated home"
}
);
},
updateFooter: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, { return await apiRequest(
method: "PATCH", fetch,
headers: { `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`,
"Content-Type": "application/json", "PATCH",
Authorization: `Bearer ${cookies.get("session_token")}` {
}, body: {
body: JSON.stringify({
additional_text: data.get("additional-text") additional_text: data.get("additional-text")
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: "Successfully updated footer"
};
}, },
pasteImage: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully updated footer"
}
);
},
pasteImage: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const file = data.get("file") as File; const file = data.get("file") as File;
const fileData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
method: "POST",
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json", Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId, "X-Website-Id": params.websiteId,
"X-Mimetype": file.type, "X-Mimetype": file.type,
"X-Original-Filename": file.name "X-Original-Filename": file.name
}, },
body: await file.arrayBuffer() body: await file.arrayBuffer(),
successMessage: "Successfully uploaded image",
returnData: true
}); });
const fileJSON = await fileData.json();
if (!fileData.ok) {
return { success: false, message: fileJSON.message };
}
return { success: true, message: "Successfully uploaded image", fileId: fileJSON.file_id };
} }
}; };

View File

@@ -1,37 +1,24 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte"; import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
import { ALLOWED_MIME_TYPES, handleImagePaste } from "$lib/utils"; import { ALLOWED_MIME_TYPES } from "$lib/utils";
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData, LayoutServerData, PageServerData } from "./$types"; import type { ActionData, LayoutServerData, PageServerData } from "./$types";
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import { previewContent } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData & LayoutServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData & LayoutServerData; form: ActionData } = $props();
let previewContent = $state(data.home.main_content); previewContent.value = data.home.main_content;
let mainContentTextarea: HTMLTextAreaElement;
let textareaScrollTop = $state(0);
const updateScrollPercentage = () => {
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
textareaScrollTop = (scrollTop / (scrollHeight - clientHeight)) * 100;
};
const handlePaste = async (event: ClipboardEvent) => {
const newContent = await handleImagePaste(event, data.API_BASE_PREFIX);
if (newContent) {
previewContent = newContent;
}
};
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -39,9 +26,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={previewContent ||
"Put some markdown content in main content to see a live preview here"}
previewScrollTop={textareaScrollTop}
> >
<section id="global"> <section id="global">
<h2> <h2>
@@ -51,26 +35,30 @@
action="?/updateGlobal" action="?/updateGlobal"
method="POST" method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
use:enhance={() => { use:enhance={enhanceForm({ reset: false })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
> >
<label> <label>
Light accent color: Background color dark theme:
<input <input
type="color" type="color"
name="accent-color-light" name="background-color-dark"
value={data.globalSettings.accent_color_light_theme} value={data.globalSettings.background_color_dark_theme}
pattern="\S(.*\S)?" pattern="\S(.*\S)?"
required required
/> />
</label> </label>
<label> <label>
Dark accent color: Background color light theme:
<input
type="color"
name="background-color-light"
value={data.globalSettings.background_color_light_theme}
pattern="\S(.*\S)?"
required
/>
</label>
<label>
Accent color dark theme:
<input <input
type="color" type="color"
name="accent-color-dark" name="accent-color-dark"
@@ -79,6 +67,16 @@
required required
/> />
</label> </label>
<label>
Accent color light theme:
<input
type="color"
name="accent-color-light"
value={data.globalSettings.accent_color_light_theme}
pattern="\S(.*\S)?"
required
/>
</label>
<div class="file-field"> <div class="file-field">
<label> <label>
Favicon: Favicon:
@@ -107,13 +105,7 @@
action="?/updateHeader" action="?/updateHeader"
method="POST" method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
use:enhance={() => { use:enhance={enhanceForm({ reset: false })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
> >
<label> <label>
Logo type: Logo type:
@@ -156,29 +148,13 @@
<a href="#home">Home</a> <a href="#home">Home</a>
</h2> </h2>
<form <form action="?/updateHome" method="POST" use:enhance={enhanceForm({ reset: false })}>
action="?/updateHome" <MarkdownEditor
method="POST" apiPrefix={data.API_BASE_PREFIX}
use:enhance={() => { label="Main content"
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
>
<label>
Main content:
<textarea
name="main-content" name="main-content"
rows="20" content={data.home.main_content}
bind:value={previewContent} />
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
onpaste={handlePaste}
required>{data.home.main_content}</textarea
>
</label>
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
@@ -189,17 +165,7 @@
<a href="#footer">Footer</a> <a href="#footer">Footer</a>
</h2> </h2>
<form <form action="?/updateFooter" method="POST" use:enhance={enhanceForm({ reset: false })}>
action="?/updateFooter"
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
>
<label> <label>
Additional text: Additional text:
<textarea name="additional-text" rows="5" maxlength="250" required <textarea name="additional-text" rows="5" maxlength="250" required

View File

@@ -1,8 +1,9 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Article, ArticleInput, DocsCategory } from "$lib/db-schema"; import { apiRequest } from "$lib/server/utils";
import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent, locals }) => { export const load: PageServerLoad = async ({ params, fetch, url, parent, locals }) => {
const searchQuery = url.searchParams.get("article_search_query"); const searchQuery = url.searchParams.get("article_search_query");
const filterBy = url.searchParams.get("article_filter"); const filterBy = url.searchParams.get("article_filter");
@@ -34,28 +35,22 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent
const constructedFetchUrl = `${baseFetchUrl}&${parameters.toString()}`; const constructedFetchUrl = `${baseFetchUrl}&${parameters.toString()}`;
const totalArticlesData = await fetch(baseFetchUrl, { const totalArticles = await apiRequest(fetch, baseFetchUrl, "HEAD", {
method: "HEAD",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact" Prefer: "count=exact"
} },
returnData: true
}); });
const totalArticleCount = Number( const totalArticleCount = Number(
totalArticlesData.headers.get("content-range")?.split("/").at(-1) totalArticles.data.headers.get("content-range")?.split("/").at(-1)
); );
const articlesData = await fetch(constructedFetchUrl, { const articles: (Article & { docs_category: DocsCategory | null })[] = (
method: "GET", await apiRequest(fetch, constructedFetchUrl, "GET", {
headers: { returnData: true
"Content-Type": "application/json", })
Authorization: `Bearer ${cookies.get("session_token")}` ).data;
}
});
const articles: (Article & { docs_category: DocsCategory | null })[] = await articlesData.json();
return { return {
totalArticleCount, totalArticleCount,
@@ -66,44 +61,22 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent
}; };
export const actions: Actions = { export const actions: Actions = {
createArticle: async ({ request, fetch, cookies, params }) => { createArticle: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/article`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/article`, "POST", {
method: "POST", body: {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
website_id: params.websiteId, website_id: params.websiteId,
title: data.get("title") as string title: data.get("title")
} satisfies ArticleInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully created article" };
}, },
deleteArticle: async ({ request, fetch, cookies }) => { successMessage: "Successfully created article"
});
},
deleteArticle: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/article?id=eq.${data.get("id")}`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/article?id=eq.${data.get("id")}`, "DELETE", {
method: "DELETE", successMessage: "Successfully deleted article"
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}); });
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully deleted article" };
} }
}; };

View File

@@ -6,15 +6,18 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false); previewContent.value = data.home.main_content;
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -22,7 +25,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={data.home.main_content}
> >
<section id="create-article"> <section id="create-article">
<h2> <h2>
@@ -32,18 +34,7 @@
<Modal id="create-article" text="Create article"> <Modal id="create-article" text="Create article">
<h3>Create article</h3> <h3>Create article</h3>
<form <form method="POST" action="?/createArticle" use:enhance={enhanceForm({ closeModal: true })}>
method="POST"
action="?/createArticle"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<label> <label>
Title: Title:
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required /> <input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
@@ -134,14 +125,7 @@
<form <form
method="POST" method="POST"
action="?/deleteArticle" action="?/deleteArticle"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="id" value={id} /> <input type="hidden" name="id" value={id} />

View File

@@ -1,43 +1,40 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { Article, DocsCategory } from "$lib/db-schema"; import type { Article, DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => { export const load: PageServerLoad = async ({ parent, params, fetch }) => {
const articleData = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, { const article: Article = (
method: "GET", await apiRequest(fetch, `${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, "GET", {
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} },
}); returnData: true
})
).data;
const categoryData = await fetch( const categories: DocsCategory[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`, `${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`,
"GET",
{ {
method: "GET", returnData: true
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
} }
} )
); ).data;
const article: Article = await articleData.json();
const categories: DocsCategory[] = await categoryData.json();
const { website } = await parent(); const { website } = await parent();
return { website, article, categories, API_BASE_PREFIX }; return { website, article, categories, API_BASE_PREFIX };
}; };
export const actions: Actions = { export const actions: Actions = {
editArticle: async ({ fetch, cookies, request, params }) => { editArticle: async ({ fetch, request, params }) => {
const data = await request.formData(); const data = await request.formData();
const coverFile = data.get("cover-image") as File; const coverFile = data.get("cover-image") as File;
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json", Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId "X-Website-Id": params.websiteId
}; };
@@ -47,66 +44,50 @@ export const actions: Actions = {
headers["X-Original-Filename"] = coverFile.name; headers["X-Original-Filename"] = coverFile.name;
} }
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
method: "POST",
headers, headers,
body: coverFile ? await coverFile.arrayBuffer() : null body: coverFile ? await coverFile.arrayBuffer() : null,
returnData: true
}); });
const uploadedImage = await uploadedImageData.json(); if (!uploadedImage.success && (coverFile?.size ?? 0 > 0)) {
if (!uploadedImageData.ok && (coverFile?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message }; return { success: false, message: uploadedImage.message };
} }
const res = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, { return await apiRequest(
method: "PATCH", fetch,
headers: { `${API_BASE_PREFIX}/article?id=eq.${params.articleId}`,
"Content-Type": "application/json", "PATCH",
Authorization: `Bearer ${cookies.get("session_token")}` {
}, body: {
body: JSON.stringify({
title: data.get("title"), title: data.get("title"),
meta_description: data.get("description"), meta_description: data.get("description"),
meta_author: data.get("author"), meta_author: data.get("author"),
cover_image: uploadedImage.file_id, cover_image: uploadedImage.data?.file_id,
publication_date: data.get("publication-date"), publication_date: data.get("publication-date"),
main_content: data.get("main-content"), main_content: data.get("main-content"),
category: data.get("category"), category: data.get("category"),
article_weight: data.get("article-weight") ? data.get("article-weight") : null article_weight: data.get("article-weight") ? data.get("article-weight") : null
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated article" };
}, },
pasteImage: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully updated article"
}
);
},
pasteImage: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const file = data.get("file") as File; const file = data.get("file") as File;
const fileData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
method: "POST",
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json", Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId, "X-Website-Id": params.websiteId,
"X-Mimetype": file.type, "X-Mimetype": file.type,
"X-Original-Filename": file.name "X-Original-Filename": file.name
}, },
body: await file.arrayBuffer() body: await file.arrayBuffer(),
successMessage: "Successfully uploaded image",
returnData: true
}); });
const fileJSON = await fileData.json();
if (!fileData.ok) {
return { success: false, message: fileJSON.message };
}
return { success: true, message: "Successfully uploaded image", fileId: fileJSON.file_id };
} }
}; };

View File

@@ -6,32 +6,19 @@
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { handleImagePaste } from "$lib/utils"; import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let previewContent = $state(data.article.main_content); previewContent.value = data.article?.main_content ?? "";
let mainContentTextarea: HTMLTextAreaElement;
let textareaScrollTop = $state(0);
const updateScrollPercentage = () => {
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
textareaScrollTop = (scrollTop / (scrollHeight - clientHeight)) * 100;
};
const handlePaste = async (event: ClipboardEvent) => {
const newContent = await handleImagePaste(event, data.API_BASE_PREFIX);
if (newContent) {
previewContent = newContent;
}
};
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -39,9 +26,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={previewContent ||
"Put some markdown content in main content to see a live preview here"}
previewScrollTop={textareaScrollTop}
> >
<section id="edit-article"> <section id="edit-article">
<h2> <h2>
@@ -52,13 +36,7 @@
method="POST" method="POST"
action="?/editArticle" action="?/editArticle"
enctype="multipart/form-data" enctype="multipart/form-data"
use:enhance={() => { use:enhance={enhanceForm({ reset: false })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
> >
{#if data.website.content_type === "Docs"} {#if data.website.content_type === "Docs"}
<label> <label>
@@ -132,18 +110,12 @@
</div> </div>
{/if} {/if}
<label> <MarkdownEditor
Main content: apiPrefix={data.API_BASE_PREFIX}
<textarea label="Main content"
name="main-content" name="main-content"
rows="20" content={data.article.main_content ?? ""}
bind:value={previewContent} />
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
onpaste={handlePaste}
required>{data.article.main_content}</textarea
>
</label>
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>

View File

@@ -1,20 +1,19 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { DocsCategory, DocsCategoryInput } from "$lib/db-schema"; import type { DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => { export const load: PageServerLoad = async ({ parent, params, fetch }) => {
const categoryData = await fetch( const categories: DocsCategory[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`, `${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`,
"GET",
{ {
method: "GET", returnData: true
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
} }
} )
); ).data;
const categories: DocsCategory[] = await categoryData.json();
const { website, home } = await parent(); const { website, home } = await parent();
return { return {
@@ -25,72 +24,44 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
}; };
export const actions: Actions = { export const actions: Actions = {
createCategory: async ({ request, fetch, cookies, params }) => { createCategory: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/docs_category`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/docs_category`, "POST", {
method: "POST", body: {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
website_id: params.websiteId, website_id: params.websiteId,
category_name: data.get("category-name") as string, category_name: data.get("category-name"),
category_weight: data.get("category-weight") as unknown as number
} satisfies DocsCategoryInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully created category" };
},
updateCategory: async ({ request, fetch, cookies, params }) => {
const data = await request.formData();
const res = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&id=eq.${data.get("category-id")}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
category_weight: data.get("category-weight") category_weight: data.get("category-weight")
})
}
);
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated category" };
}, },
deleteCategory: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully created category"
});
},
updateCategory: async ({ request, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch( return await apiRequest(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&id=eq.${data.get("category-id")}`, fetch,
`${API_BASE_PREFIX}/docs_category?id=eq.${data.get("category-id")}`,
"PATCH",
{ {
method: "DELETE", body: {
headers: { category_name: data.get("category-name"),
"Content-Type": "application/json", category_weight: data.get("category-weight")
Authorization: `Bearer ${cookies.get("session_token")}` },
} successMessage: "Successfully updated category"
} }
); );
},
deleteCategory: async ({ request, fetch }) => {
const data = await request.formData();
if (!res.ok) { return await apiRequest(
const response = await res.json(); fetch,
return { success: false, message: response.message }; `${API_BASE_PREFIX}/docs_category?id=eq.${data.get("category-id")}`,
"DELETE",
{
successMessage: "Successfully deleted category"
} }
);
return { success: true, message: "Successfully deleted category" };
} }
}; };

View File

@@ -5,15 +5,18 @@
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false); previewContent.value = data.home.main_content;
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -21,7 +24,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={data.home.main_content}
> >
<section id="create-category"> <section id="create-category">
<h2> <h2>
@@ -31,18 +33,7 @@
<Modal id="create-category" text="Create category"> <Modal id="create-category" text="Create category">
<h3>Create category</h3> <h3>Create category</h3>
<form <form method="POST" action="?/createCategory" use:enhance={enhanceForm({ closeModal: true })}>
method="POST"
action="?/createCategory"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<label> <label>
Name: Name:
<input type="text" name="category-name" maxlength="50" required /> <input type="text" name="category-name" maxlength="50" required />
@@ -78,17 +69,21 @@
<form <form
method="POST" method="POST"
action="?/updateCategory" action="?/updateCategory"
use:enhance={() => { use:enhance={enhanceForm({ reset: false, closeModal: true })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="category-id" value={id} /> <input type="hidden" name="category-id" value={id} />
<label>
Name:
<input
type="text"
name="category-name"
value={category_name}
maxlength="50"
required
/>
</label>
<label> <label>
Weight: Weight:
<input type="number" name="category-weight" value={category_weight} min="0" /> <input type="number" name="category-weight" value={category_weight} min="0" />
@@ -105,14 +100,7 @@
<form <form
method="POST" method="POST"
action="?/deleteCategory" action="?/deleteCategory"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="category-id" value={id} /> <input type="hidden" name="category-id" value={id} />

View File

@@ -1,22 +1,20 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { Collab, CollabInput, User } from "$lib/db-schema"; import type { Collab, User } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) => { export const load: PageServerLoad = async ({ parent, params, fetch }) => {
const { website, home } = await parent(); const collaborators: (Collab & { user: User })[] = (
await apiRequest(
const collabData = await fetch( fetch,
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`, `${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`,
"GET",
{ {
method: "GET", returnData: true
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
} }
} )
); ).data;
const collaborators: (Collab & { user: User })[] = await collabData.json(); const { website, home } = await parent();
return { return {
website, website,
@@ -26,83 +24,61 @@ export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) =
}; };
export const actions: Actions = { export const actions: Actions = {
addCollaborator: async ({ request, fetch, cookies, params }) => { addCollaborator: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const userData = await fetch(`${API_BASE_PREFIX}/user?username=eq.${data.get("username")}`, { const user: User = (
method: "GET", await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?username=eq.${data.get("username")}`,
"GET",
{
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
}
});
const user: User = await userData.json();
const res = await fetch(`${API_BASE_PREFIX}/collab`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}, },
body: JSON.stringify({ returnData: true
}
)
).data;
if (!user) {
return { success: false, message: "Specified user could not be found" };
}
return await apiRequest(fetch, `${API_BASE_PREFIX}/collab`, "POST", {
body: {
website_id: params.websiteId, website_id: params.websiteId,
user_id: user.id, user_id: user.id,
permission_level: data.get("permission-level") as unknown as number
} satisfies CollabInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully added collaborator" };
},
updateCollaborator: async ({ request, fetch, cookies, params }) => {
const data = await request.formData();
const res = await fetch(
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&user_id=eq.${data.get("user-id")}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
permission_level: data.get("permission-level") permission_level: data.get("permission-level")
})
}
);
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated collaborator" };
}, },
removeCollaborator: async ({ request, fetch, cookies, params }) => { successMessage: "Successfully added collaborator"
});
},
updateCollaborator: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch( return await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&user_id=eq.${data.get("user-id")}`, `${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&user_id=eq.${data.get("user-id")}`,
"PATCH",
{ {
method: "DELETE", body: {
headers: { permission_level: data.get("permission-level")
"Content-Type": "application/json", },
Authorization: `Bearer ${cookies.get("session_token")}` successMessage: "Successfully updated collaborator"
}
} }
); );
},
removeCollaborator: async ({ request, fetch, params }) => {
const data = await request.formData();
if (!res.ok) { return await apiRequest(
const response = await res.json(); fetch,
return { success: false, message: response.message }; `${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&user_id=eq.${data.get("user-id")}`,
"DELETE",
{
successMessage: "Successfully removed collaborator"
} }
);
return { success: true, message: "Successfully removed collaborator" };
} }
}; };

View File

@@ -4,16 +4,18 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { enhanceForm } from "$lib/utils";
import { previewContent, sending } from "$lib/runes.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false); previewContent.value = data.home.main_content;
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -21,7 +23,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={data.home.main_content}
> >
<section id="add-collaborator"> <section id="add-collaborator">
<h2> <h2>
@@ -34,14 +35,7 @@
<form <form
method="POST" method="POST"
action="?/addCollaborator" action="?/addCollaborator"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<label> <label>
Username: Username:
@@ -82,14 +76,7 @@
<form <form
method="POST" method="POST"
action="?/updateCollaborator" action="?/updateCollaborator"
use:enhance={() => { use:enhance={enhanceForm({ reset: false, closeModal: true })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="user-id" value={user_id} /> <input type="hidden" name="user-id" value={user_id} />
@@ -113,14 +100,7 @@
<form <form
method="POST" method="POST"
action="?/removeCollaborator" action="?/removeCollaborator"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<input type="hidden" name="user-id" value={user_id} /> <input type="hidden" name="user-id" value={user_id} />

View File

@@ -1,74 +1,61 @@
import type { Actions, PageServerLoad } from "./$types"; import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { LegalInformation, LegalInformationInput } from "$lib/db-schema"; import type { LegalInformation } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params, cookies }) => { export const load: PageServerLoad = async ({ parent, fetch, params }) => {
const legalInformationData = await fetch( const legalInformation: LegalInformation = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`, `${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"GET",
{ {
method: "GET",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
},
returnData: true
} }
} )
); ).data;
const legalInformation: LegalInformation = await legalInformationData.json();
const { website } = await parent(); const { website } = await parent();
return { return {
legalInformation, legalInformation,
website website,
API_BASE_PREFIX
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
createUpdateLegalInformation: async ({ request, fetch, cookies, params }) => { createUpdateLegalInformation: async ({ request, fetch, params }) => {
const data = await request.formData(); const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/legal_information`, { return await apiRequest(fetch, `${API_BASE_PREFIX}/legal_information`, "POST", {
method: "POST",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "resolution=merge-duplicates", Prefer: "resolution=merge-duplicates",
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
}, },
body: JSON.stringify({ body: {
website_id: params.websiteId, website_id: params.websiteId,
main_content: data.get("main-content") as string main_content: data.get("main-content")
} satisfies LegalInformationInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: `Successfully ${res.status === 201 ? "created" : "updated"} legal information`
};
}, },
deleteLegalInformation: async ({ fetch, cookies, params }) => { successMessage: "Successfully created/updated legal information"
const res = await fetch( });
},
deleteLegalInformation: async ({ fetch, params }) => {
const deleteLegalInformation = await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`, `${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"DELETE",
{ {
method: "DELETE", successMessage: "Successfully deleted legal information"
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
} }
); );
if (!res.ok) { if (!deleteLegalInformation.success) {
const response = await res.json(); return deleteLegalInformation;
return { success: false, message: response.message };
} }
await rm( await rm(
@@ -76,6 +63,6 @@ export const actions: Actions = {
{ force: true } { force: true }
); );
return { success: true, message: `Successfully deleted legal information` }; return deleteLegalInformation;
} }
}; };

View File

@@ -4,25 +4,19 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import Modal from "$lib/components/Modal.svelte"; import Modal from "$lib/components/Modal.svelte";
import { enhanceForm } from "$lib/utils";
import { sending, previewContent } from "$lib/runes.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let previewContent = $state(data.legalInformation.main_content); previewContent.value = data.legalInformation?.main_content ?? "";
let mainContentTextarea: HTMLTextAreaElement;
let textareaScrollTop = $state(0);
const updateScrollPercentage = () => {
const { scrollTop, scrollHeight, clientHeight } = mainContentTextarea;
textareaScrollTop = (scrollTop / (scrollHeight - clientHeight)) * 100;
};
let sending = $state(false);
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -30,9 +24,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={previewContent ||
"Put some markdown content in main content to see a live preview here"}
previewScrollTop={textareaScrollTop}
> >
<section id="legal-information"> <section id="legal-information">
<h2> <h2>
@@ -61,45 +52,24 @@
<form <form
method="POST" method="POST"
action="?/createUpdateLegalInformation" action="?/createUpdateLegalInformation"
use:enhance={() => { use:enhance={enhanceForm({ reset: false })}
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
> >
<label> <MarkdownEditor
Main content: apiPrefix={data.API_BASE_PREFIX}
<textarea label="Main content"
name="main-content" name="main-content"
rows="20" content={data.legalInformation?.main_content ?? ""}
placeholder="## Impressum />
## Privacy policy"
bind:value={previewContent}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
required>{data.legalInformation.main_content ?? ""}</textarea
>
</label>
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
{#if data.legalInformation.main_content} {#if data.legalInformation?.main_content}
<Modal id="delete-legal-information" text="Delete"> <Modal id="delete-legal-information" text="Delete">
<form <form
action="?/deleteLegalInformation" action="?/deleteLegalInformation"
method="post" method="post"
use:enhance={() => { use:enhance={enhanceForm({ closeModal: true })}
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
> >
<h3>Delete legal information</h3> <h3>Delete legal information</h3>
<p> <p>

View File

@@ -1,8 +1,8 @@
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { ChangeLog, User, Collab } from "$lib/db-schema"; import type { ChangeLog, User, Collab } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params, cookies, url }) => { export const load: PageServerLoad = async ({ parent, fetch, params, url }) => {
const userFilter = url.searchParams.get("logs_filter_user"); const userFilter = url.searchParams.get("logs_filter_user");
const resourceFilter = url.searchParams.get("logs_filter_resource"); const resourceFilter = url.searchParams.get("logs_filter_resource");
const operationFilter = url.searchParams.get("logs_filter_operation"); const operationFilter = url.searchParams.get("logs_filter_operation");
@@ -27,41 +27,30 @@ export const load: PageServerLoad = async ({ parent, fetch, params, cookies, url
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=50&offset=${resultOffset}`; const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}&limit=50&offset=${resultOffset}`;
const changeLogData = await fetch(constructedFetchUrl, { const changeLog: (ChangeLog & { user: { username: User["username"] } })[] = (
method: "GET", await apiRequest(fetch, constructedFetchUrl, "GET", { returnData: true })
headers: { ).data;
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
});
const resultChangeLogData = await fetch(constructedFetchUrl, { const resultChangeLogData = await apiRequest(fetch, constructedFetchUrl, "HEAD", {
method: "HEAD",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact" Prefer: "count=exact"
} },
returnData: true
}); });
const resultChangeLogCount = Number( const resultChangeLogCount = Number(
resultChangeLogData.headers.get("content-range")?.split("/").at(-1) resultChangeLogData.data.headers.get("content-range")?.split("/").at(-1)
); );
const collabData = await fetch( const collaborators: (Collab & { user: User })[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`, `${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`,
{ "GET",
method: "GET", { returnData: true }
headers: { )
"Content-Type": "application/json", ).data;
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] =
await changeLogData.json();
const collaborators: (Collab & { user: User })[] = await collabData.json();
const { website, home } = await parent(); const { website, home } = await parent();
return { return {

View File

@@ -6,6 +6,8 @@
import diff from "fast-diff"; import diff from "fast-diff";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { tables } from "$lib/db-schema"; import { tables } from "$lib/db-schema";
import { previewContent } from "$lib/runes.svelte";
import { sanitize } from "isomorphic-dompurify";
const { data }: { data: PageServerData } = $props(); const { data }: { data: PageServerData } = $props();
@@ -34,15 +36,19 @@
let resources = $state({}); let resources = $state({});
if (data.website.content_type === "Blog") { if (data.website.content_type === "Blog") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, docs_category, ...restTables } = tables; const { user, change_log, media, docs_category, ...restTables } = tables;
resources = restTables; resources = restTables;
} }
if (data.website.content_type === "Docs") { if (data.website.content_type === "Docs") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, ...restTables } = tables; const { user, change_log, media, ...restTables } = tables;
resources = restTables; resources = restTables;
} }
previewContent.value = data.home.main_content;
let logsSection: HTMLElement; let logsSection: HTMLElement;
</script> </script>
@@ -50,7 +56,6 @@
id={data.website.id} id={data.website.id}
contentType={data.website.content_type} contentType={data.website.content_type}
title={data.website.title} title={data.website.title}
previewContent={data.home.main_content}
> >
<section id="logs" bind:this={logsSection}> <section id="logs" bind:this={logsSection}>
<hgroup> <hgroup>
@@ -151,13 +156,17 @@
<p>{table_name} &mdash; {operation}</p> <p>{table_name} &mdash; {operation}</p>
</hgroup> </hgroup>
<pre style="white-space: pre-wrap">{@html htmlDiff(oldValue, newValue)}</pre> <pre style="white-space: pre-wrap">{@html sanitize(htmlDiff(oldValue, newValue), {
ALLOWED_TAGS: ["ins", "del"]
})}</pre>
</Modal> </Modal>
</td> </td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div>
<div class="pagination">
{#snippet commonFilterInputs()} {#snippet commonFilterInputs()}
<input <input
type="hidden" type="hidden"
@@ -175,7 +184,6 @@
value={$page.url.searchParams.get("logs_filter_operation")} value={$page.url.searchParams.get("logs_filter_operation")}
/> />
{/snippet} {/snippet}
<div class="pagination">
<p> <p>
{$page.url.searchParams.get("logs_results_page") ?? 1} / {Math.max( {$page.url.searchParams.get("logs_results_page") ?? 1} / {Math.max(
Math.ceil(data.resultChangeLogCount / 50), Math.ceil(data.resultChangeLogCount / 50),
@@ -187,8 +195,7 @@
{@render commonFilterInputs()} {@render commonFilterInputs()}
<button <button
type="submit" type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"} disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}>First</button
>First</button
> >
</form> </form>
<form method="GET"> <form method="GET">
@@ -237,7 +244,6 @@
> >
</form> </form>
</div> </div>
</div>
</section> </section>
</WebsiteEditor> </WebsiteEditor>
@@ -245,8 +251,6 @@
.pagination { .pagination {
display: flex; display: flex;
align-items: center; align-items: center;
margin-inline: var(--space-2xs);
margin-block: var(--space-s);
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-xs); gap: var(--space-xs);
justify-content: end; justify-content: end;
@@ -256,8 +260,8 @@
margin-inline-start: auto; margin-inline-start: auto;
} }
button[disabled] { button:disabled {
opacity: 0.5;
pointer-events: none; pointer-events: none;
color: hsl(0 0% 50%);
} }
</style> </style>

View File

@@ -1,29 +1,29 @@
import { readFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type WebsiteOverview, slugify } from "$lib/utils";
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { render } from "svelte/server";
import BlogIndex from "$lib/templates/blog/BlogIndex.svelte";
import BlogArticle from "$lib/templates/blog/BlogArticle.svelte";
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import BlogArticle from "$lib/templates/blog/BlogArticle.svelte";
import BlogIndex from "$lib/templates/blog/BlogIndex.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import { type WebsiteOverview, hexToHSL, slugify } from "$lib/utils";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { render } from "svelte/server";
import type { Actions, PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, fetch, cookies }) => { export const load: PageServerLoad = async ({ params, fetch }) => {
const websiteOverviewData = await fetch( const websiteOverview: WebsiteOverview = (
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`, await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`,
"GET",
{ {
method: "GET",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
},
returnData: true
} }
} )
); ).data;
const websiteOverview: WebsiteOverview = await websiteOverviewData.json();
generateStaticFiles(websiteOverview); generateStaticFiles(websiteOverview);
@@ -36,10 +36,13 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
}/previews/${websiteOverview.id}/`; }/previews/${websiteOverview.id}/`;
const websiteProdUrl = dev const websiteProdUrl = dev
? `http://localhost:18000/${websiteOverview.id}/` ? `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`
: process.env.ORIGIN : process.env.ORIGIN
? process.env.ORIGIN.replace("//", `//${websiteOverview.id}.`) ? process.env.ORIGIN.replace(
: `http://localhost:18000/${websiteOverview.id}/`; "//",
`//${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}.`
)
: `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`;
return { return {
websiteOverview, websiteOverview,
@@ -49,43 +52,110 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
publishWebsite: async ({ fetch, params, cookies }) => { publishWebsite: async ({ fetch, params }) => {
const websiteOverviewData = await fetch( const websiteOverview: WebsiteOverview = (
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`, await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*),domain_prefix(*)`,
"GET",
{ {
method: "GET",
headers: { headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
},
returnData: true
} }
)
).data;
generateStaticFiles(websiteOverview, false);
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`,
"PATCH",
{
body: {
is_published: true
},
successMessage: "Successfully published website"
}
);
},
createUpdateCustomDomainPrefix: async ({ request, fetch, params }) => {
const data = await request.formData();
const oldDomainPrefix = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`,
"GET",
{
headers: {
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
)
).data;
const newDomainPrefix = await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix`, "POST", {
headers: {
Prefer: "resolution=merge-duplicates",
Accept: "application/vnd.pgrst.object+json"
},
body: {
website_id: params.websiteId,
prefix: data.get("domain-prefix")
},
successMessage: "Successfully created/updated domain prefix"
});
if (!newDomainPrefix.success) {
return newDomainPrefix;
}
await rename(
join(
"/",
"var",
"www",
"archtika-websites",
oldDomainPrefix?.prefix ? oldDomainPrefix.prefix : params.websiteId
),
join("/", "var", "www", "archtika-websites", data.get("domain-prefix") as string)
);
return newDomainPrefix;
},
deleteCustomDomainPrefix: async ({ fetch, params }) => {
const customPrefix = await apiRequest(
fetch,
`${API_BASE_PREFIX}/domain_prefix?website_id=eq.${params.websiteId}`,
"DELETE",
{
headers: {
Prefer: "return=representation",
Accept: "application/vnd.pgrst.object+json"
},
successMessage: "Successfully deleted domain prefix",
returnData: true
} }
); );
const websiteOverview = await websiteOverviewData.json(); if (!customPrefix.success) {
generateStaticFiles(websiteOverview, false); return customPrefix;
const res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
is_published: true
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
} }
return { success: true, message: "Successfully published website" }; await rename(
join("/", "var", "www", "archtika-websites", customPrefix.data.prefix),
join("/", "var", "www", "archtika-websites", params.websiteId)
);
return customPrefix;
} }
}; };
const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: boolean = true) => { const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview = true) => {
const fileContents = (head: string, body: string) => { const fileContents = (head: string, body: string) => {
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
@@ -112,7 +182,13 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: bool
if (isPreview) { if (isPreview) {
uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id); uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id);
} else { } else {
uploadDir = join("/", "var", "www", "archtika-websites", websiteData.id); uploadDir = join(
"/",
"var",
"www",
"archtika-websites",
websiteData.domain_prefix?.prefix ?? websiteData.id
);
} }
await mkdir(uploadDir, { recursive: true }); await mkdir(uploadDir, { recursive: true });
@@ -157,21 +233,35 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: bool
encoding: "utf-8" encoding: "utf-8"
} }
); );
const {
h: hDark,
s: sDark,
l: lDark
} = hexToHSL(websiteData.settings.background_color_dark_theme);
const {
h: hLight,
s: sLight,
l: lLight
} = hexToHSL(websiteData.settings.background_color_light_theme);
await writeFile( await writeFile(
join(uploadDir, "styles.css"), join(uploadDir, "styles.css"),
commonStyles commonStyles
.concat(specificStyles) .concat(specificStyles)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_H \*\/\s*).*(?=;)/, ` ${hDark}`)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_S \*\/\s*).*(?=;)/, ` ${sDark}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_DARK_THEME_L \*\/\s*).*(?=;)/, ` ${lDark}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_H \*\/\s*).*(?=;)/, ` ${hLight}`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_S \*\/\s*).*(?=;)/, ` ${sLight}%`)
.replace(/(?<=\/\* BACKGROUND_COLOR_LIGHT_THEME_L \*\/\s*).*(?=;)/, ` ${lLight}%`)
.replace( .replace(
/--color-accent:\s*(.*?);/, /(?<=\/\* ACCENT_COLOR_DARK_THEME \*\/\s*).*(?=;)/,
`--color-accent: ${websiteData.settings.accent_color_dark_theme};` ` ${websiteData.settings.accent_color_dark_theme}`
) )
.replace( .replace(
/@media\s*\(prefers-color-scheme:\s*dark\)\s*{[^}]*--color-accent:\s*(.*?);/, /(?<=\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*).*(?=;)/,
(match) => ` ${websiteData.settings.accent_color_light_theme}`
match.replace(
/--color-accent:\s*(.*?);/,
`--color-accent: ${websiteData.settings.accent_color_light_theme};`
)
) )
); );
}; };

View File

@@ -4,15 +4,19 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte"; import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData, PageServerData } from "./$types"; import type { ActionData, PageServerData } from "./$types";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import Modal from "$lib/components/Modal.svelte";
import { enhanceForm } from "$lib/utils";
import { sending } from "$lib/runes.svelte";
import { previewContent } from "$lib/runes.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props(); const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false); previewContent.value = data.websitePreviewUrl;
</script> </script>
<SuccessOrError success={form?.success} message={form?.message} /> <SuccessOrError success={form?.success} message={form?.message} />
{#if sending} {#if sending.value}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
@@ -20,7 +24,6 @@
id={data.websiteOverview.id} id={data.websiteOverview.id}
contentType={data.websiteOverview.content_type} contentType={data.websiteOverview.content_type}
title={data.websiteOverview.title} title={data.websiteOverview.title}
previewContent={data.websitePreviewUrl}
fullPreview={true} fullPreview={true}
> >
<section id="publish-website"> <section id="publish-website">
@@ -32,31 +35,63 @@
is published. If you are happy with the results, click the button below and your website will is published. If you are happy with the results, click the button below and your website will
be published on the Internet. be published on the Internet.
</p> </p>
<form <form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
method="POST"
action="?/publishWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<button type="submit">Publish</button> <button type="submit">Publish</button>
</form> </form>
</section>
{#if data.websiteOverview.is_published} {#if data.websiteOverview.is_published}
<section id="publication-status"> <section id="publication-status">
<h3> <h2>
<a href="#publication-status">Publication status</a> <a href="#publication-status">Publication status</a>
</h3> </h2>
<p> <p>
Your website is published at: Your website is published at:
<br /> <br />
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a> <a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p> </p>
</section> </section>
<section id="custom-domain-prefix">
<h2>
<a href="#custom-domain-prefix">Custom domain prefix</a>
</h2>
<form
method="POST"
action="?/createUpdateCustomDomainPrefix"
use:enhance={enhanceForm({ reset: false })}
>
<label>
Prefix:
<input
type="text"
name="domain-prefix"
value={data.websiteOverview.domain_prefix?.prefix ?? ""}
placeholder="my-blog"
minlength="3"
maxlength="16"
pattern="^[a-z]+(-[a-z]+)*$"
required
/>
</label>
<button type="submit">Submit</button>
</form>
{#if data.websiteOverview.domain_prefix?.prefix}
<Modal id="delete-domain-prefix" text="Delete">
<form
action="?/deleteCustomDomainPrefix"
method="post"
use:enhance={enhanceForm({ closeModal: true })}
>
<h3>Delete domain prefix</h3>
<p>
<strong>Caution!</strong>
This action will remove the domain prefix and reset it to its initial value.
</p>
<button type="submit">Delete domain prefix</button>
</form>
</Modal>
{/if} {/if}
</section> </section>
{/if}
</WebsiteEditor> </WebsiteEditor>

View File

@@ -5,6 +5,7 @@
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { navigating } from "$app/stores"; import { navigating } from "$app/stores";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"; import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { LOADING_DELAY } from "$lib/utils";
const { data, children }: { data: LayoutServerData; children: Snippet } = $props(); const { data, children }: { data: LayoutServerData; children: Snippet } = $props();
@@ -14,23 +15,43 @@
? "Dashboard" ? "Dashboard"
: `${$page.url.pathname.charAt(1).toUpperCase()}${$page.url.pathname.slice(2)}` : `${$page.url.pathname.charAt(1).toUpperCase()}${$page.url.pathname.slice(2)}`
); );
let loading = $state(false);
let loadingDelay: number;
$effect(() => {
if ($navigating && ["link", "goto"].includes($navigating.type)) {
loadingDelay = window.setTimeout(() => (loading = true), LOADING_DELAY);
} else {
window.clearTimeout(loadingDelay);
loading = false;
}
});
</script> </script>
{#if $navigating && ["link", "goto"].includes($navigating.type)} {#if loading}
<LoadingSpinner /> <LoadingSpinner />
{/if} {/if}
<svelte:head> <svelte:head>
<title>archtika | {routeName.replaceAll("/", " - ")}</title> <title>archtika | {routeName.replaceAll("/", " - ")}</title>
<meta
name="description"
content="FLOSS, modern, performant and lightweight CMS (Content Mangement System) with predefined templates"
/>
</svelte:head> </svelte:head>
<nav> <nav>
{#if data.user}
<div class="logo-wrapper">
<img src="/favicon.svg" width="24" height="24" alt="" /> <img src="/favicon.svg" width="24" height="24" alt="" />
<a href="/">archtika</a>
</div>
{:else}
<img src="/favicon.svg" width="24" height="24" alt="" />
{/if}
<ul class="link-wrapper unpadded"> <ul class="link-wrapper unpadded">
{#if data.user} {#if data.user}
<li>
<a href="/">Dashboard</a>
</li>
<li> <li>
<a href="/account">Account</a> <a href="/account">Account</a>
</li> </li>
@@ -83,6 +104,12 @@
justify-content: space-between; justify-content: space-between;
} }
nav > .logo-wrapper {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
nav > .link-wrapper { nav > .link-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -11,7 +11,23 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter() adapter: adapter(),
csp: {
mode: "auto",
directives: {
"default-src": ["none"],
"script-src": ["self"],
"style-src": ["self", "https:", "unsafe-inline"],
"img-src": ["self", "data:", "https:", "http:"],
"font-src": ["self", "https:"],
"connect-src": ["self"],
"frame-src": ["self", "https:", "http:"],
"object-src": ["none"],
"base-uri": ["self"],
"frame-ancestors": ["none"],
"form-action": ["self"]
}
}
} }
}; };

View File

@@ -19,16 +19,22 @@
} }
:root { :root {
--bg-primary: white; --bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
--bg-secondary: hsl(0 0% 95%); --bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
--bg-tertiary: hsl(0 0% 90%); --bg-primary-l: /* BACKGROUND_COLOR_LIGHT_THEME_L */ 100%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) - 20%)
);
--color-text: black; --color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
--color-text-invert: white; --color-text-invert: var(--bg-primary);
--color-border: hsl(0 0% 50%); --color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
--color-accent: hsl(210, 100%, 30%); --color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
--color-success: hsl(105, 100%, 30%); --color-success: hsl(105 100% 30%);
--color-error: hsl(0, 100%, 30%); --color-error: hsl(0 100% 30%);
--border-primary: 0.0625rem solid var(--color-border); --border-primary: 0.0625rem solid var(--color-border);
--border-radius: 0.125rem; --border-radius: 0.125rem;
@@ -72,15 +78,22 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg-primary: hsl(0 0% 15%); --bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
--bg-secondary: hsl(0 0% 20%); --bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
--bg-tertiary: hsl(0 0% 25%); --bg-primary-l: /* BACKGROUND_COLOR_DARK_THEME_L */ 15%;
--bg-primary: hsl(var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l));
--bg-secondary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 5%));
--bg-tertiary: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 10%));
--bg-blurred: hsla(
var(--bg-primary-h) var(--bg-primary-s) var(--bg-primary-l) / calc(var(--bg-primary-l) + 20%)
);
--color-text: white; --color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
--color-text-invert: black; --color-text-invert: var(--bg-primary);
--color-accent: hsl(210, 100%, 80%); --color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
--color-success: hsl(105, 100%, 80%); --color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
--color-error: hsl(0, 100%, 80%); --color-success: hsl(105 100% 80%);
--color-error: hsl(0 100% 80%);
color-scheme: dark; color-scheme: dark;
} }

View File

@@ -86,8 +86,8 @@ test.describe.serial("Collaborator tests", () => {
await page.getByRole("link", { name: "Documentation" }).click(); await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click(); await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click(); await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click(); await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").fill("Category-10"); await page.getByLabel("Name:").nth(0).fill("Category-10");
await page.getByLabel("Weight:").click(); await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("10"); await page.getByLabel("Weight:").fill("10");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
@@ -99,8 +99,8 @@ test.describe.serial("Collaborator tests", () => {
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Legal information" }).click(); await page.getByRole("link", { name: "Legal information" }).click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click(); await page.getByLabel("Main content:").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content"); await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
}); });
@@ -330,22 +330,22 @@ test.describe.serial("Collaborator tests", () => {
test("Create/Update legal information", async ({ page }) => { test("Create/Update legal information", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Legal information" }).click(); await page.getByRole("link", { name: "Legal information" }).click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click(); await page.getByLabel("Main content:").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content"); await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) { if (permissionLevel === 30) {
await expect(page.getByText("Successfully created legal")).toBeVisible(); await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
} else { } else {
await expect(page.getByText("Insufficient permissions")).toBeVisible(); await expect(page.getByText("Insufficient permissions")).toBeVisible();
} }
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click(); await page.getByLabel("Main content:").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated"); await page.getByLabel("Main content:").fill("## Content updated");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) { if (permissionLevel === 30) {
await expect(page.getByText("Successfully updated legal")).toBeVisible(); await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
} else { } else {
await expect(page.getByText("Insufficient permissions")).toBeVisible(); await expect(page.getByText("Insufficient permissions")).toBeVisible();
} }
@@ -370,8 +370,8 @@ test.describe.serial("Collaborator tests", () => {
await page.getByRole("link", { name: "Documentation" }).click(); await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click(); await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click(); await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click(); await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").fill(`Category-${permissionLevel}`); await page.getByLabel("Name:").nth(0).fill(`Category-${permissionLevel}`);
await page.getByRole("spinbutton", { name: "Weight:" }).click(); await page.getByRole("spinbutton", { name: "Weight:" }).click();
await page.getByRole("spinbutton", { name: "Weight:" }).fill(permissionLevel.toString()); await page.getByRole("spinbutton", { name: "Weight:" }).fill(permissionLevel.toString());
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();

View File

@@ -80,10 +80,14 @@ test.describe.serial("Website tests", () => {
test.describe.serial("Update settings", () => { test.describe.serial("Update settings", () => {
test("Global", async ({ authenticatedPage: page }) => { test("Global", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Blog" }).click();
await page.getByLabel("Light accent color:").click(); await page.getByLabel("Background color dark theme: ").click();
await page.getByLabel("Light accent color:").fill("#3975a2"); await page.getByLabel("Background color dark theme:").fill("#3975a2");
await page.getByLabel("Dark accent color:").click(); await page.getByLabel("Background color light theme:").click();
await page.getByLabel("Dark accent color:").fill("#41473e"); await page.getByLabel("Background color light theme:").fill("#41473e");
await page.getByLabel("Accent color dark theme: ").click();
await page.getByLabel("Accent color dark theme:").fill("#3975a2");
await page.getByLabel("Accent color light theme:").click();
await page.getByLabel("Accent color light theme:").fill("#41473e");
await page.locator("#global").getByRole("button", { name: "Submit" }).click(); await page.locator("#global").getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated global")).toBeVisible(); await expect(page.getByText("Successfully updated global")).toBeVisible();
await page.getByLabel("Favicon:").click(); await page.getByLabel("Favicon:").click();
@@ -235,15 +239,15 @@ test.describe.serial("Website tests", () => {
test("Create/Update legal information", async ({ authenticatedPage: page }) => { test("Create/Update legal information", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Legal information" }).click(); await page.getByRole("link", { name: "Legal information" }).click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click(); await page.getByLabel("Main content:").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content"); await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully created legal")).toBeVisible(); await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click(); await page.getByLabel("Main content:").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated"); await page.getByLabel("Main content:").fill("## Content updated");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Successfully updated legal")).toBeVisible(); await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
}); });
test("Delete legal information", async ({ authenticatedPage: page }) => { test("Delete legal information", async ({ authenticatedPage: page }) => {
await page.getByRole("link", { name: "Blog" }).click(); await page.getByRole("link", { name: "Blog" }).click();
@@ -261,8 +265,8 @@ test.describe.serial("Website tests", () => {
await page.getByRole("link", { name: "Documentation" }).click(); await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click(); await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click(); await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click(); await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").fill("Category"); await page.getByLabel("Name:").nth(0).fill("Category");
await page.getByLabel("Weight:").click(); await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("1000"); await page.getByLabel("Weight:").fill("1000");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();
@@ -294,8 +298,8 @@ test.describe.serial("Website tests", () => {
await page.getByRole("link", { name: "Documentation" }).click(); await page.getByRole("link", { name: "Documentation" }).click();
await page.getByRole("link", { name: "Categories" }).click(); await page.getByRole("link", { name: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click(); await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click(); await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").fill("Category"); await page.getByLabel("Name:").nth(0).fill("Category");
await page.getByLabel("Weight:").click(); await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("1000"); await page.getByLabel("Weight:").fill("1000");
await page.getByRole("button", { name: "Submit" }).click(); await page.getByRole("button", { name: "Submit" }).click();