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": {
"nixpkgs": {
"locked": {
"lastModified": 1721497942,
"narHash": "sha256-EDPL9qJfklXoowl3nEBmjDIqcvXKUZInt5n6CCc1Hn4=",
"lastModified": 1726463316,
"narHash": "sha256-gI9kkaH0ZjakJOKrdjaI/VbaMEo9qBbSUl93DnU7f4c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d43f0636fc9492e83be8bbb41f9595d7a87106b8",
"rev": "99dc8785f6a0adac95f5e2ab05cc2e1bf666d172",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}

View File

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

View File

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

View File

@@ -75,6 +75,12 @@ in
default = null;
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 {
@@ -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
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 = ''
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;
recommendedProxySettings = 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 = {
"${cfg.domain}" = {
@@ -166,13 +186,16 @@ in
proxyPass = "http://localhost:${toString cfg.apiPort}/";
extraConfig = ''
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;
forceSSL = true;
locations = {

View File

@@ -25,7 +25,8 @@ CREATE TABLE internal.user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
username VARCHAR(16) UNIQUE NOT NULL CHECK (LENGTH(username) >= 3),
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 (
@@ -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,
content_type VARCHAR(10) CHECK (content_type IN ('Blog', 'Docs')) NOT NULL,
title VARCHAR(50) NOT NULL CHECK (TRIM(title) != ''),
created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
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_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
title_search TSVECTOR GENERATED ALWAYS AS (TO_TSVECTOR('english', title)) STORED
@@ -52,8 +53,10 @@ CREATE TABLE internal.media (
CREATE TABLE internal.settings (
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_dark_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#114678',
accent_color_dark_theme CHAR(7) CHECK (accent_color_light_theme ~ '^#[a-fA-F0-9]{6}$') NOT NULL DEFAULT '#a5d8ff',
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,
last_modified_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
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,
category_name VARCHAR(50) NOT NULL CHECK (TRIM(category_name) != ''),
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_by UUID REFERENCES internal.user (id) ON DELETE SET NULL,
UNIQUE (website_id, category_name),
@@ -117,6 +121,7 @@ CREATE TABLE internal.footer (
CREATE TABLE internal.legal_information (
website_id UUID PRIMARY KEY REFERENCES internal.website (id) ON DELETE CASCADE,
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_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, 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 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 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 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, 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 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, 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, 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 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;

View File

@@ -30,7 +30,8 @@ BEGIN
RETURN COALESCE(NEW, OLD);
END;
$$
LANGUAGE plpgsql;
LANGUAGE plpgsql
SECURITY DEFINER;
CREATE TRIGGER update_website_last_modified
BEFORE UPDATE ON internal.website
@@ -68,7 +69,7 @@ CREATE TRIGGER update_footer_last_modified
EXECUTE FUNCTION internal.update_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
EXECUTE FUNCTION internal.update_last_modified ();

View File

@@ -8,7 +8,7 @@ DECLARE
_website_id UUID := (_headers ->> 'x-website-id')::UUID;
_mimetype TEXT := _headers ->> 'x-mimetype';
_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;
_has_access BOOLEAN;
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"
},
"devDependencies": {
"@playwright/test": "1.40.0",
"@sveltejs/adapter-auto": "3.2.4",
"@sveltejs/adapter-node": "5.2.2",
"@sveltejs/kit": "2.5.22",
"@sveltejs/vite-plugin-svelte": "3.1.1",
"@playwright/test": "1.46.0",
"@sveltejs/adapter-auto": "3.2.5",
"@sveltejs/adapter-node": "5.2.3",
"@sveltejs/kit": "2.5.28",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
"@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3",
"@types/eslint-config-prettier": "6.11.3",
"@types/node": "22.2.0",
"@types/node": "22.5.5",
"eslint": "9.10.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-svelte": "2.43.0",
"eslint-plugin-svelte": "2.44.0",
"globals": "15.9.0",
"pg-to-ts": "4.1.1",
"prettier": "3.3.3",
"prettier-plugin-svelte": "3.2.6",
"svelte": "5.0.0-next.220",
"svelte-check": "3.8.5",
"typescript": "5.5.4",
"typescript-eslint": "8.4.0",
"vite": "5.4.0"
"svelte": "5.0.0-next.253",
"svelte-check": "4.0.2",
"typescript": "5.6.2",
"typescript-eslint": "8.6.0",
"vite": "5.4.6"
},
"type": "module",
"dependencies": {
"fast-diff": "1.3.0",
"highlight.js": "11.10.0",
"isomorphic-dompurify": "2.14.0",
"marked": "14.0.0",
"isomorphic-dompurify": "2.15.0",
"marked": "14.1.2",
"marked-highlight": "2.1.4"
}
}

View File

@@ -1,32 +1,36 @@
import { redirect } from "@sveltejs/kit";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { User } from "$lib/db-schema";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
export const handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith("/api/")) {
const userData = await event.fetch(`${API_BASE_PREFIX}/account`, {
method: "GET",
const userData = await apiRequest(event.fetch, `${API_BASE_PREFIX}/account`, "GET", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${event.cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
}
Accept: "application/vnd.pgrst.object+json",
Authorization: `Bearer ${event.cookies.get("session_token")}`
},
returnData: true
});
if (!userData.ok && !["/login", "/register"].includes(event.url.pathname)) {
if (!userData.success && !["/login", "/register"].includes(event.url.pathname)) {
throw redirect(303, "/login");
}
if (userData.ok) {
if (userData.success) {
if (["/login", "/register"].includes(event.url.pathname)) {
throw redirect(303, "/");
}
const user: User = await userData.json();
event.locals.user = user;
event.locals.user = userData.data;
}
}
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 {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
background-color: var(--bg-blurred);
z-index: 40;
}
@@ -27,6 +27,6 @@
border: var(--border-primary);
border-width: 0.125rem;
border-block-start-color: var(--color-accent);
animation: spinner 0.6s linear infinite;
animation: spinner 500ms linear infinite;
}
</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;
inset: 0;
z-index: 10;
background-color: rgba(0, 0, 0, 0.5);
background-color: var(--bg-blurred);
}
.modal__content {

View File

@@ -2,30 +2,27 @@
import type { Snippet } from "svelte";
import { md } from "$lib/utils";
import { page } from "$app/stores";
import { previewContent, textareaScrollTop } from "$lib/runes.svelte";
const {
id,
contentType,
title,
children,
fullPreview = false,
previewContent,
previewScrollTop = 0
fullPreview = false
}: {
id: string;
contentType: string;
title: string;
children: Snippet;
fullPreview?: boolean;
previewContent: string;
previewScrollTop?: number;
} = $props();
let previewElement: HTMLDivElement;
$effect(() => {
const scrollHeight = previewElement.scrollHeight - previewElement.clientHeight;
previewElement.scrollTop = (previewScrollTop / 100) * scrollHeight;
previewElement.scrollTop = (textareaScrollTop.value / 100) * scrollHeight;
});
</script>
@@ -66,9 +63,12 @@
<div class="preview" bind:this={previewElement}>
{#if fullPreview}
<iframe src={previewContent} title="Preview"></iframe>
<iframe src={previewContent.value} title="Preview"></iframe>
{: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}
</div>

View File

@@ -5,7 +5,7 @@
* AUTO-GENERATED FILE - DO NOT EDIT!
*
* 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;
category_name: string;
category_weight: number;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
@@ -178,6 +179,7 @@ export interface DocsCategoryInput {
user_id?: string | null;
category_name: string;
category_weight: number;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
}
@@ -189,6 +191,7 @@ const docs_category = {
"user_id",
"category_name",
"category_weight",
"created_at",
"last_modified_at",
"last_modified_by"
],
@@ -203,6 +206,34 @@ const docs_category = {
$input: null as unknown as DocsCategoryInput
} 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
export interface Footer {
website_id: string;
@@ -297,18 +328,20 @@ const home = {
export interface LegalInformation {
website_id: string;
main_content: string;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface LegalInformationInput {
website_id: string;
main_content: string;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
}
const 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"],
primaryKey: "website_id",
foreignKeys: {
@@ -354,16 +387,20 @@ const media = {
// Table settings
export interface Settings {
website_id: string;
accent_color_light_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;
last_modified_at: Date;
last_modified_by: string | null;
}
export interface SettingsInput {
website_id: string;
accent_color_light_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;
last_modified_at?: Date;
last_modified_by?: string | null;
@@ -372,8 +409,10 @@ const settings = {
tableName: "settings",
columns: [
"website_id",
"accent_color_light_theme",
"accent_color_dark_theme",
"accent_color_light_theme",
"background_color_dark_theme",
"background_color_light_theme",
"favicon_image",
"last_modified_at",
"last_modified_by"
@@ -395,16 +434,18 @@ export interface User {
username: string;
password_hash: string;
role: string;
created_at: Date;
}
export interface UserInput {
id?: string;
username: string;
password_hash: string;
role?: string;
created_at?: Date;
}
const user = {
tableName: "user",
columns: ["id", "username", "password_hash", "role"],
columns: ["id", "username", "password_hash", "role", "created_at"],
requiredForInsert: ["username", "password_hash"],
primaryKey: "id",
foreignKeys: {},
@@ -418,8 +459,8 @@ export interface Website {
user_id: string;
content_type: string;
title: string;
created_at: Date;
is_published: boolean;
created_at: Date;
last_modified_at: Date;
last_modified_by: string | null;
title_search: any | null;
@@ -429,8 +470,8 @@ export interface WebsiteInput {
user_id?: string;
content_type: string;
title: string;
created_at?: Date;
is_published?: boolean;
created_at?: Date;
last_modified_at?: Date;
last_modified_by?: string | null;
title_search?: any | null;
@@ -442,8 +483,8 @@ const website = {
"user_id",
"content_type",
"title",
"created_at",
"is_published",
"created_at",
"last_modified_at",
"last_modified_by",
"title_search"
@@ -475,6 +516,10 @@ export interface TableTypes {
select: DocsCategory;
input: DocsCategoryInput;
};
domain_prefix: {
select: DomainPrefix;
input: DomainPrefixInput;
};
footer: {
select: Footer;
input: FooterInput;
@@ -514,6 +559,7 @@ export const tables = {
change_log,
collab,
docs_category,
domain_prefix,
footer,
header,
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
? "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">
import type { WebsiteOverview } from "../../utils";
import { type WebsiteOverview, md } from "../../utils";
const {
websiteOverview,
@@ -10,7 +10,7 @@
<footer>
<div class="container">
<small>
{@html websiteOverview.footer.additional_text.replace(
{@html md(websiteOverview.footer.additional_text, false).replace(
"!!legal",
`<a href="${isIndexPage ? "./legal-information" : "../legal-information"}">Legal information</a>`
)}

View File

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

View File

@@ -12,10 +12,20 @@ import type {
Footer,
Article,
DocsCategory,
LegalInformation
LegalInformation,
DomainPrefix
} 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) => {
return string
@@ -24,8 +34,8 @@ export const slugify = (string: string) => {
.toLowerCase() // Convert to lowercase
.trim() // Trim leading and trailing whitespace
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/[^\w\-]+/g, "") // Remove non-word characters (except hyphens)
.replace(/\-\-+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/[^\w-]+/g, "") // Remove non-word characters (except hyphens)
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-+/, "") // Remove leading hyphens
.replace(/-+$/, ""); // Remove trailing hyphens
};
@@ -51,8 +61,8 @@ const createMarkdownParser = (showToc = true) => {
);
const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => {
let headings: { text: string; level: number; id: string }[] = [];
let sectionStack: { level: number; id: string }[] = [];
const headings: { text: string; level: number; id: string }[] = [];
const sectionStack: { level: number; id: string }[] = [];
return {
renderer: {
@@ -143,45 +153,59 @@ export const md = (markdownContent: string, showToc = true) => {
return html;
};
export const handleImagePaste = async (event: ClipboardEvent, API_BASE_PREFIX: string) => {
const clipboardItems = Array.from(event.clipboardData?.items ?? []);
const file = clipboardItems.find((item) => item.type.startsWith("image/"));
export const LOADING_DELAY = 500;
let loadingDelay: number;
if (!file) return null;
export const enhanceForm = (options?: {
reset?: boolean;
closeModal?: boolean;
}): SubmitFunction => {
return () => {
loadingDelay = window.setTimeout(() => (sending.value = true), LOADING_DELAY);
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)[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 "";
return async ({ update }) => {
await update({ reset: options?.reset ?? true });
window.clearTimeout(loadingDelay);
if (options?.closeModal) {
window.location.hash = "!";
}
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 {
@@ -191,4 +215,5 @@ export interface WebsiteOverview extends Website {
footer: Footer;
article: (Article & { docs_category: DocsCategory | null })[];
legal_information?: LegalInformation;
domain_prefix?: DomainPrefix;
}

View File

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

View File

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

View File

@@ -1,25 +1,22 @@
import type { Actions } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Actions, PageServerLoad } from "./$types";
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 = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/register`, "POST", {
body: {
username: data.get("username"),
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">
import { enhance } from "$app/forms";
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 { sending } from "$lib/runes.svelte";
import { enhanceForm } from "$lib/utils";
const { form }: { form: ActionData } = $props();
let sending = $state(false);
const { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
<form
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
{#if data.REGISTRATION_IS_DISABLED}
<p class="registration-disabled">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
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>
Username:
<input type="text" name="username" minlength="3" maxlength="16" required />
@@ -35,4 +45,13 @@
</label>
<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 { apiRequest } from "$lib/server/utils";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { rm } from "node:fs/promises";
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 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 totalWebsitesData = await fetch(baseFetchUrl, {
method: "HEAD",
const totalWebsites = await apiRequest(fetch, baseFetchUrl, "HEAD", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact"
}
},
returnData: true
});
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, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
});
const websites: Website[] = await websiteData.json();
const websites: Website[] = (
await apiRequest(fetch, constructedFetchUrl, "GET", {
returnData: true
})
).data;
return {
totalWebsiteCount,
@@ -57,70 +52,63 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => {
};
export const actions: Actions = {
createWebsite: async ({ request, fetch, cookies }) => {
createWebsite: async ({ request, fetch }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/create_website`, {
method: "POST",
headers: {
"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({
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/create_website`, "POST", {
body: {
content_type: data.get("content-type"),
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 res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
return await apiRequest(fetch, `${API_BASE_PREFIX}/website?id=eq.${data.get("id")}`, "PATCH", {
body: {
title: data.get("title")
},
successMessage: "Successfully updated website"
});
},
deleteWebsite: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get("id");
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
const oldDomainPrefix = (
await apiRequest(fetch, `${API_BASE_PREFIX}/domain_prefix?website_id=eq.${id}`, "GET", {
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,
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 type { ActionData, PageServerData } from "./$types";
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();
let sending = $state(false);
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -26,18 +26,7 @@
<Modal id="create-website" text="Create website">
<h3>Create website</h3>
<form
method="POST"
action="?/createWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<form method="POST" action="?/createWebsite" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Type:
<select name="content-type">
@@ -119,14 +108,7 @@
<form
method="POST"
action="?/updateWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false, closeModal: true })}
>
<input type="hidden" name="id" value={id} />
<label>
@@ -154,14 +136,7 @@
<form
method="POST"
action="?/deleteWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="id" value={id} />
@@ -179,7 +154,7 @@
.website-grid {
display: grid;
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);
}

View File

@@ -1,5 +1,5 @@
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 }) => {
return {
@@ -16,24 +16,18 @@ export const actions: Actions = {
deleteAccount: async ({ request, fetch, cookies }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/rpc/delete_account`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
const response = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/delete_account`, "POST", {
body: {
pass: data.get("password")
})
},
successMessage: "Successfully deleted account"
});
const response = await res.json();
if (!res.ok) {
return { success: false, message: response.message };
if (!response.success) {
return response;
}
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 LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
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();
let sending = $state(false);
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -38,17 +38,7 @@
<a href="#logout">Logout</a>
</h2>
<form
method="POST"
action="?/logout"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<form method="POST" action="?/logout" use:enhance={enhanceForm()}>
<button type="submit">Logout</button>
</form>
</section>
@@ -66,18 +56,7 @@
Deleting your account will irretrievably erase all data.
</p>
<form
method="POST"
action="?/deleteAccount"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<form method="POST" action="?/deleteAccount" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Password:
<input type="password" name="password" required />
@@ -87,3 +66,9 @@
</form>
</Modal>
</section>
<style>
form[action="?/logout"] > button {
max-inline-size: fit-content;
}
</style>

View File

@@ -1,36 +1,35 @@
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 type { Website, Home, User } from "$lib/db-schema";
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
const websiteData = await fetch(
export const load: LayoutServerLoad = async ({ params, fetch }) => {
const websiteData = await apiRequest(
fetch,
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
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");
}
const homeData = await fetch(`${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, {
method: "GET",
const home: Home = (
await apiRequest(fetch, `${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, "GET", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
}
});
const website: Website & { user: { username: User["username"] } } = await websiteData.json();
const home: Home = await homeData.json();
},
returnData: true
})
).data;
return {
website,

View File

@@ -1,41 +1,40 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import { apiRequest } from "$lib/server/utils";
import type { Settings, Header, Footer } from "$lib/db-schema";
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
const globalSettingsData = await fetch(
export const load: PageServerLoad = async ({ params, fetch }) => {
const globalSettings: Settings = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
}
);
)
).data;
const headerData = await fetch(`${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, {
method: "GET",
const header: Header = (
await apiRequest(fetch, `${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, "GET", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
}
});
},
returnData: true
})
).data;
const footerData = await fetch(`${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, {
method: "GET",
const footer: Footer = (
await apiRequest(fetch, `${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, "GET", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
}
});
const globalSettings: Settings = await globalSettingsData.json();
const header: Header = await headerData.json();
const footer: Footer = await footerData.json();
},
returnData: true
})
).data;
return {
globalSettings,
@@ -46,13 +45,12 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
};
export const actions: Actions = {
updateGlobal: async ({ request, fetch, cookies, params }) => {
updateGlobal: async ({ request, fetch, params }) => {
const data = await request.formData();
const faviconFile = data.get("favicon") as File;
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId
};
@@ -62,48 +60,38 @@ export const actions: Actions = {
headers["X-Original-Filename"] = faviconFile.name;
}
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, {
method: "POST",
const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers,
body: faviconFile ? await faviconFile.arrayBuffer() : null
body: faviconFile ? await faviconFile.arrayBuffer() : null,
returnData: true
});
const uploadedImage = await uploadedImageData.json();
if (!uploadedImageData.ok && (faviconFile?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message };
if (!uploadedImage.success && (faviconFile?.size ?? 0 > 0)) {
return uploadedImage;
}
const res = await fetch(`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
accent_color_light_theme: data.get("accent-color-light"),
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/settings?website_id=eq.${params.websiteId}`,
"PATCH",
{
body: {
accent_color_dark_theme: data.get("accent-color-dark"),
favicon_image: uploadedImage.file_id
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: "Successfully updated global settings"
};
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
},
updateHeader: async ({ request, fetch, cookies, params }) => {
successMessage: "Successfully updated global settings"
}
);
},
updateHeader: async ({ request, fetch, params }) => {
const data = await request.formData();
const logoImage = data.get("logo-image") as File;
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId
};
@@ -113,109 +101,75 @@ export const actions: Actions = {
headers["X-Original-Filename"] = logoImage.name;
}
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, {
method: "POST",
const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers,
body: logoImage ? await logoImage.arrayBuffer() : null
body: logoImage ? await logoImage.arrayBuffer() : null,
returnData: true
});
const uploadedImage = await uploadedImageData.json();
if (!uploadedImageData.ok && (logoImage?.size ?? 0 > 0)) {
if (!uploadedImage.success && (logoImage?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message };
}
const res = await fetch(`${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/header?website_id=eq.${params.websiteId}`,
"PATCH",
{
body: {
logo_type: data.get("logo-type"),
logo_text: data.get("logo-text"),
logo_image: uploadedImage.file_id
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return {
success: true,
message: "Successfully updated header"
};
logo_image: uploadedImage.data?.file_id
},
updateHome: async ({ request, fetch, cookies, params }) => {
successMessage: "Successfully updated header"
}
);
},
updateHome: async ({ request, fetch, params }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/home?website_id=eq.${params.websiteId}`,
"PATCH",
{
body: {
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 res = await fetch(`${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/footer?website_id=eq.${params.websiteId}`,
"PATCH",
{
body: {
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 file = data.get("file") as File;
const fileData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, {
method: "POST",
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers: {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Mimetype": file.type,
"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">
import { enhance } from "$app/forms";
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 type { ActionData, LayoutServerData, PageServerData } from "./$types";
import Modal from "$lib/components/Modal.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();
let previewContent = $state(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);
previewContent.value = data.home.main_content;
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -39,9 +26,6 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={previewContent ||
"Put some markdown content in main content to see a live preview here"}
previewScrollTop={textareaScrollTop}
>
<section id="global">
<h2>
@@ -51,26 +35,30 @@
action="?/updateGlobal"
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false })}
>
<label>
Light accent color:
Background color dark theme:
<input
type="color"
name="accent-color-light"
value={data.globalSettings.accent_color_light_theme}
name="background-color-dark"
value={data.globalSettings.background_color_dark_theme}
pattern="\S(.*\S)?"
required
/>
</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
type="color"
name="accent-color-dark"
@@ -79,6 +67,16 @@
required
/>
</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">
<label>
Favicon:
@@ -107,13 +105,7 @@
action="?/updateHeader"
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false })}
>
<label>
Logo type:
@@ -156,29 +148,13 @@
<a href="#home">Home</a>
</h2>
<form
action="?/updateHome"
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
>
<label>
Main content:
<textarea
<form action="?/updateHome" method="POST" use:enhance={enhanceForm({ reset: false })}>
<MarkdownEditor
apiPrefix={data.API_BASE_PREFIX}
label="Main content"
name="main-content"
rows="20"
bind:value={previewContent}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
onpaste={handlePaste}
required>{data.home.main_content}</textarea
>
</label>
content={data.home.main_content}
/>
<button type="submit">Submit</button>
</form>
@@ -189,17 +165,7 @@
<a href="#footer">Footer</a>
</h2>
<form
action="?/updateFooter"
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
>
<form action="?/updateFooter" method="POST" use:enhance={enhanceForm({ reset: false })}>
<label>
Additional text:
<textarea name="additional-text" rows="5" maxlength="250" required

View File

@@ -1,8 +1,9 @@
import type { Actions, PageServerLoad } from "./$types";
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 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 totalArticlesData = await fetch(baseFetchUrl, {
method: "HEAD",
const totalArticles = await apiRequest(fetch, baseFetchUrl, "HEAD", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact"
}
},
returnData: true
});
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, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
});
const articles: (Article & { docs_category: DocsCategory | null })[] = await articlesData.json();
const articles: (Article & { docs_category: DocsCategory | null })[] = (
await apiRequest(fetch, constructedFetchUrl, "GET", {
returnData: true
})
).data;
return {
totalArticleCount,
@@ -66,44 +61,22 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, url, parent
};
export const actions: Actions = {
createArticle: async ({ request, fetch, cookies, params }) => {
createArticle: async ({ request, fetch, params }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/article`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(fetch, `${API_BASE_PREFIX}/article`, "POST", {
body: {
website_id: params.websiteId,
title: data.get("title") as string
} satisfies ArticleInput)
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully created article" };
title: data.get("title")
},
deleteArticle: async ({ request, fetch, cookies }) => {
successMessage: "Successfully created article"
});
},
deleteArticle: async ({ request, fetch }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/article?id=eq.${data.get("id")}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
return await apiRequest(fetch, `${API_BASE_PREFIX}/article?id=eq.${data.get("id")}`, "DELETE", {
successMessage: "Successfully deleted article"
});
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 LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
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();
let sending = $state(false);
previewContent.value = data.home.main_content;
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -22,7 +25,6 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="create-article">
<h2>
@@ -32,18 +34,7 @@
<Modal id="create-article" text="Create article">
<h3>Create article</h3>
<form
method="POST"
action="?/createArticle"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<form method="POST" action="?/createArticle" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Title:
<input type="text" name="title" pattern="\S(.*\S)?" maxlength="100" required />
@@ -134,14 +125,7 @@
<form
method="POST"
action="?/deleteArticle"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="id" value={id} />

View File

@@ -1,43 +1,40 @@
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";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
const articleData = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, {
method: "GET",
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
const article: Article = (
await apiRequest(fetch, `${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, "GET", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
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`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
returnData: true
}
}
);
)
).data;
const article: Article = await articleData.json();
const categories: DocsCategory[] = await categoryData.json();
const { website } = await parent();
return { website, article, categories, API_BASE_PREFIX };
};
export const actions: Actions = {
editArticle: async ({ fetch, cookies, request, params }) => {
editArticle: async ({ fetch, request, params }) => {
const data = await request.formData();
const coverFile = data.get("cover-image") as File;
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId
};
@@ -47,66 +44,50 @@ export const actions: Actions = {
headers["X-Original-Filename"] = coverFile.name;
}
const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, {
method: "POST",
const uploadedImage = await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers,
body: coverFile ? await coverFile.arrayBuffer() : null
body: coverFile ? await coverFile.arrayBuffer() : null,
returnData: true
});
const uploadedImage = await uploadedImageData.json();
if (!uploadedImageData.ok && (coverFile?.size ?? 0 > 0)) {
if (!uploadedImage.success && (coverFile?.size ?? 0 > 0)) {
return { success: false, message: uploadedImage.message };
}
const res = await fetch(`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/article?id=eq.${params.articleId}`,
"PATCH",
{
body: {
title: data.get("title"),
meta_description: data.get("description"),
meta_author: data.get("author"),
cover_image: uploadedImage.file_id,
cover_image: uploadedImage.data?.file_id,
publication_date: data.get("publication-date"),
main_content: data.get("main-content"),
category: data.get("category"),
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 file = data.get("file") as File;
const fileData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, {
method: "POST",
return await apiRequest(fetch, `${API_BASE_PREFIX}/rpc/upload_file`, "POST", {
headers: {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json",
"X-Website-Id": params.websiteId,
"X-Mimetype": file.type,
"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 Modal from "$lib/components/Modal.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();
let previewContent = $state(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);
previewContent.value = data.article?.main_content ?? "";
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -39,9 +26,6 @@
id={data.website.id}
contentType={data.website.content_type}
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">
<h2>
@@ -52,13 +36,7 @@
method="POST"
action="?/editArticle"
enctype="multipart/form-data"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false })}
>
{#if data.website.content_type === "Docs"}
<label>
@@ -132,18 +110,12 @@
</div>
{/if}
<label>
Main content:
<textarea
<MarkdownEditor
apiPrefix={data.API_BASE_PREFIX}
label="Main content"
name="main-content"
rows="20"
bind:value={previewContent}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
onpaste={handlePaste}
required>{data.article.main_content}</textarea
>
</label>
content={data.article.main_content ?? ""}
/>
<button type="submit">Submit</button>
</form>

View File

@@ -1,20 +1,19 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { DocsCategory, DocsCategoryInput } from "$lib/db-schema";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { DocsCategory } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
const categoryData = await fetch(
export const load: PageServerLoad = async ({ parent, params, fetch }) => {
const categories: DocsCategory[] = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
returnData: true
}
}
);
)
).data;
const categories: DocsCategory[] = await categoryData.json();
const { website, home } = await parent();
return {
@@ -25,72 +24,44 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
};
export const actions: Actions = {
createCategory: async ({ request, fetch, cookies, params }) => {
createCategory: async ({ request, fetch, params }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/docs_category`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
return await apiRequest(fetch, `${API_BASE_PREFIX}/docs_category`, "POST", {
body: {
website_id: params.websiteId,
category_name: data.get("category-name") as string,
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_name: data.get("category-name"),
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 res = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&id=eq.${data.get("category-id")}`,
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/docs_category?id=eq.${data.get("category-id")}`,
"PATCH",
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
body: {
category_name: data.get("category-name"),
category_weight: data.get("category-weight")
},
successMessage: "Successfully updated category"
}
);
},
deleteCategory: async ({ request, fetch }) => {
const data = await request.formData();
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
return await apiRequest(
fetch,
`${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 LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
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();
let sending = $state(false);
previewContent.value = data.home.main_content;
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -21,7 +24,6 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="create-category">
<h2>
@@ -31,18 +33,7 @@
<Modal id="create-category" text="Create category">
<h3>Create category</h3>
<form
method="POST"
action="?/createCategory"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
>
<form method="POST" action="?/createCategory" use:enhance={enhanceForm({ closeModal: true })}>
<label>
Name:
<input type="text" name="category-name" maxlength="50" required />
@@ -78,17 +69,21 @@
<form
method="POST"
action="?/updateCategory"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false, closeModal: true })}
>
<input type="hidden" name="category-id" value={id} />
<label>
Name:
<input
type="text"
name="category-name"
value={category_name}
maxlength="50"
required
/>
</label>
<label>
Weight:
<input type="number" name="category-weight" value={category_weight} min="0" />
@@ -105,14 +100,7 @@
<form
method="POST"
action="?/deleteCategory"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="category-id" value={id} />

View File

@@ -1,22 +1,20 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { Collab, CollabInput, User } from "$lib/db-schema";
import { API_BASE_PREFIX, apiRequest } from "$lib/server/utils";
import type { Collab, User } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) => {
const { website, home } = await parent();
const collabData = await fetch(
export const load: PageServerLoad = async ({ parent, params, 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`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
returnData: true
}
}
);
)
).data;
const collaborators: (Collab & { user: User })[] = await collabData.json();
const { website, home } = await parent();
return {
website,
@@ -26,83 +24,61 @@ export const load: PageServerLoad = async ({ parent, params, fetch, cookies }) =
};
export const actions: Actions = {
addCollaborator: async ({ request, fetch, cookies, params }) => {
addCollaborator: async ({ request, fetch, params }) => {
const data = await request.formData();
const userData = await fetch(`${API_BASE_PREFIX}/user?username=eq.${data.get("username")}`, {
method: "GET",
const user: User = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/user?username=eq.${data.get("username")}`,
"GET",
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
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,
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")
})
}
);
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 res = await fetch(
return await apiRequest(
fetch,
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&user_id=eq.${data.get("user-id")}`,
"PATCH",
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
body: {
permission_level: data.get("permission-level")
},
successMessage: "Successfully updated collaborator"
}
);
},
removeCollaborator: async ({ request, fetch, params }) => {
const data = await request.formData();
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
return await apiRequest(
fetch,
`${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 Modal from "$lib/components/Modal.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";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
let sending = $state(false);
previewContent.value = data.home.main_content;
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -21,7 +23,6 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="add-collaborator">
<h2>
@@ -34,14 +35,7 @@
<form
method="POST"
action="?/addCollaborator"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<label>
Username:
@@ -82,14 +76,7 @@
<form
method="POST"
action="?/updateCollaborator"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false, closeModal: true })}
>
<input type="hidden" name="user-id" value={user_id} />
@@ -113,14 +100,7 @@
<form
method="POST"
action="?/removeCollaborator"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<input type="hidden" name="user-id" value={user_id} />

View File

@@ -1,74 +1,61 @@
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 { 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 }) => {
const legalInformationData = await fetch(
export const load: PageServerLoad = async ({ parent, fetch, params }) => {
const legalInformation: LegalInformation = (
await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"GET",
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
}
);
)
).data;
const legalInformation: LegalInformation = await legalInformationData.json();
const { website } = await parent();
return {
legalInformation,
website
website,
API_BASE_PREFIX
};
};
export const actions: Actions = {
createUpdateLegalInformation: async ({ request, fetch, cookies, params }) => {
createUpdateLegalInformation: async ({ request, fetch, params }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/legal_information`, {
method: "POST",
return await apiRequest(fetch, `${API_BASE_PREFIX}/legal_information`, "POST", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "resolution=merge-duplicates",
Accept: "application/vnd.pgrst.object+json"
},
body: JSON.stringify({
body: {
website_id: params.websiteId,
main_content: data.get("main-content") as string
} 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`
};
main_content: data.get("main-content")
},
deleteLegalInformation: async ({ fetch, cookies, params }) => {
const res = await fetch(
successMessage: "Successfully created/updated legal information"
});
},
deleteLegalInformation: async ({ fetch, params }) => {
const deleteLegalInformation = await apiRequest(
fetch,
`${API_BASE_PREFIX}/legal_information?website_id=eq.${params.websiteId}`,
"DELETE",
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
successMessage: "Successfully deleted legal information"
}
);
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
if (!deleteLegalInformation.success) {
return deleteLegalInformation;
}
await rm(
@@ -76,6 +63,6 @@ export const actions: Actions = {
{ 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 LoadingSpinner from "$lib/components/LoadingSpinner.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 MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
let previewContent = $state(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);
previewContent.value = data.legalInformation?.main_content ?? "";
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -30,9 +24,6 @@
id={data.website.id}
contentType={data.website.content_type}
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">
<h2>
@@ -61,45 +52,24 @@
<form
method="POST"
action="?/createUpdateLegalInformation"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
};
}}
use:enhance={enhanceForm({ reset: false })}
>
<label>
Main content:
<textarea
<MarkdownEditor
apiPrefix={data.API_BASE_PREFIX}
label="Main content"
name="main-content"
rows="20"
placeholder="## Impressum
## Privacy policy"
bind:value={previewContent}
bind:this={mainContentTextarea}
onscroll={updateScrollPercentage}
required>{data.legalInformation.main_content ?? ""}</textarea
>
</label>
content={data.legalInformation?.main_content ?? ""}
/>
<button type="submit">Submit</button>
</form>
{#if data.legalInformation.main_content}
{#if data.legalInformation?.main_content}
<Modal id="delete-legal-information" text="Delete">
<form
action="?/deleteLegalInformation"
method="post"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
window.location.hash = "!";
sending = false;
};
}}
use:enhance={enhanceForm({ closeModal: true })}
>
<h3>Delete legal information</h3>
<p>

View File

@@ -1,8 +1,8 @@
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";
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 resourceFilter = url.searchParams.get("logs_filter_resource");
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 changeLogData = await fetch(constructedFetchUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
});
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] = (
await apiRequest(fetch, constructedFetchUrl, "GET", { returnData: true })
).data;
const resultChangeLogData = await fetch(constructedFetchUrl, {
method: "HEAD",
const resultChangeLogData = await apiRequest(fetch, constructedFetchUrl, "HEAD", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact"
}
},
returnData: true
});
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`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
"GET",
{ returnData: true }
)
).data;
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] =
await changeLogData.json();
const collaborators: (Collab & { user: User })[] = await collabData.json();
const { website, home } = await parent();
return {

View File

@@ -6,6 +6,8 @@
import diff from "fast-diff";
import { page } from "$app/stores";
import { tables } from "$lib/db-schema";
import { previewContent } from "$lib/runes.svelte";
import { sanitize } from "isomorphic-dompurify";
const { data }: { data: PageServerData } = $props();
@@ -34,15 +36,19 @@
let resources = $state({});
if (data.website.content_type === "Blog") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, docs_category, ...restTables } = tables;
resources = restTables;
}
if (data.website.content_type === "Docs") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, change_log, media, ...restTables } = tables;
resources = restTables;
}
previewContent.value = data.home.main_content;
let logsSection: HTMLElement;
</script>
@@ -50,7 +56,6 @@
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="logs" bind:this={logsSection}>
<hgroup>
@@ -151,13 +156,17 @@
<p>{table_name} &mdash; {operation}</p>
</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>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="pagination">
{#snippet commonFilterInputs()}
<input
type="hidden"
@@ -175,7 +184,6 @@
value={$page.url.searchParams.get("logs_filter_operation")}
/>
{/snippet}
<div class="pagination">
<p>
{$page.url.searchParams.get("logs_results_page") ?? 1} / {Math.max(
Math.ceil(data.resultChangeLogCount / 50),
@@ -187,8 +195,7 @@
{@render commonFilterInputs()}
<button
type="submit"
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}
>First</button
disabled={($page.url.searchParams.get("logs_results_page") ?? "1") === "1"}>First</button
>
</form>
<form method="GET">
@@ -237,7 +244,6 @@
>
</form>
</div>
</div>
</section>
</WebsiteEditor>
@@ -245,8 +251,6 @@
.pagination {
display: flex;
align-items: center;
margin-inline: var(--space-2xs);
margin-block: var(--space-s);
flex-wrap: wrap;
gap: var(--space-xs);
justify-content: end;
@@ -256,8 +260,8 @@
margin-inline-start: auto;
}
button[disabled] {
opacity: 0.5;
button:disabled {
pointer-events: none;
color: hsl(0 0% 50%);
}
</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 { 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 }) => {
const websiteOverviewData = await fetch(
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
export const load: PageServerLoad = async ({ params, fetch }) => {
const websiteOverview: WebsiteOverview = (
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: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
},
returnData: true
}
}
);
const websiteOverview: WebsiteOverview = await websiteOverviewData.json();
)
).data;
generateStaticFiles(websiteOverview);
@@ -36,10 +36,13 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
}/previews/${websiteOverview.id}/`;
const websiteProdUrl = dev
? `http://localhost:18000/${websiteOverview.id}/`
? `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`
: process.env.ORIGIN
? process.env.ORIGIN.replace("//", `//${websiteOverview.id}.`)
: `http://localhost:18000/${websiteOverview.id}/`;
? process.env.ORIGIN.replace(
"//",
`//${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}.`
)
: `http://localhost:18000/${websiteOverview.domain_prefix?.prefix ?? websiteOverview.id}/`;
return {
websiteOverview,
@@ -49,43 +52,110 @@ export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
};
export const actions: Actions = {
publishWebsite: async ({ fetch, params, cookies }) => {
const websiteOverviewData = await fetch(
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,settings(*),header(*),home(*),footer(*),article(*,docs_category(*)),legal_information(*)`,
publishWebsite: async ({ fetch, params }) => {
const websiteOverview: WebsiteOverview = (
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: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
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();
generateStaticFiles(websiteOverview, false);
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 };
if (!customPrefix.success) {
return customPrefix;
}
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) => {
return `
<!DOCTYPE html>
@@ -112,7 +182,13 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: bool
if (isPreview) {
uploadDir = join("/", "var", "www", "archtika-websites", "previews", websiteData.id);
} 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 });
@@ -157,21 +233,35 @@ const generateStaticFiles = async (websiteData: WebsiteOverview, isPreview: bool
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(
join(uploadDir, "styles.css"),
commonStyles
.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(
/--color-accent:\s*(.*?);/,
`--color-accent: ${websiteData.settings.accent_color_dark_theme};`
/(?<=\/\* ACCENT_COLOR_DARK_THEME \*\/\s*).*(?=;)/,
` ${websiteData.settings.accent_color_dark_theme}`
)
.replace(
/@media\s*\(prefers-color-scheme:\s*dark\)\s*{[^}]*--color-accent:\s*(.*?);/,
(match) =>
match.replace(
/--color-accent:\s*(.*?);/,
`--color-accent: ${websiteData.settings.accent_color_light_theme};`
)
/(?<=\/\* ACCENT_COLOR_LIGHT_THEME \*\/\s*).*(?=;)/,
` ${websiteData.settings.accent_color_light_theme}`
)
);
};

View File

@@ -4,15 +4,19 @@
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import type { ActionData, PageServerData } from "./$types";
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();
let sending = $state(false);
previewContent.value = data.websitePreviewUrl;
</script>
<SuccessOrError success={form?.success} message={form?.message} />
{#if sending}
{#if sending.value}
<LoadingSpinner />
{/if}
@@ -20,7 +24,6 @@
id={data.websiteOverview.id}
contentType={data.websiteOverview.content_type}
title={data.websiteOverview.title}
previewContent={data.websitePreviewUrl}
fullPreview={true}
>
<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
be published on the Internet.
</p>
<form
method="POST"
action="?/publishWebsite"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update();
sending = false;
};
}}
>
<form method="POST" action="?/publishWebsite" use:enhance={enhanceForm()}>
<button type="submit">Publish</button>
</form>
</section>
{#if data.websiteOverview.is_published}
<section id="publication-status">
<h3>
<h2>
<a href="#publication-status">Publication status</a>
</h3>
</h2>
<p>
Your website is published at:
<br />
<a href={data.websiteProdUrl}>{data.websiteProdUrl}</a>
</p>
</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}
</section>
{/if}
</WebsiteEditor>

View File

@@ -5,6 +5,7 @@
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte";
import { LOADING_DELAY } from "$lib/utils";
const { data, children }: { data: LayoutServerData; children: Snippet } = $props();
@@ -14,23 +15,43 @@
? "Dashboard"
: `${$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>
{#if $navigating && ["link", "goto"].includes($navigating.type)}
{#if loading}
<LoadingSpinner />
{/if}
<svelte:head>
<title>archtika | {routeName.replaceAll("/", " - ")}</title>
<meta
name="description"
content="FLOSS, modern, performant and lightweight CMS (Content Mangement System) with predefined templates"
/>
</svelte:head>
<nav>
{#if data.user}
<div class="logo-wrapper">
<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">
{#if data.user}
<li>
<a href="/">Dashboard</a>
</li>
<li>
<a href="/account">Account</a>
</li>
@@ -83,6 +104,12 @@
justify-content: space-between;
}
nav > .logo-wrapper {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
nav > .link-wrapper {
display: flex;
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.
// 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.
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 {
--bg-primary: white;
--bg-secondary: hsl(0 0% 95%);
--bg-tertiary: hsl(0 0% 90%);
--bg-primary-h: /* BACKGROUND_COLOR_LIGHT_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_LIGHT_THEME_S */ 0%;
--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-invert: white;
--color-border: hsl(0 0% 50%);
--color-accent: hsl(210, 100%, 30%);
--color-success: hsl(105, 100%, 30%);
--color-error: hsl(0, 100%, 30%);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 0%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) - 50%));
--color-accent: /* ACCENT_COLOR_LIGHT_THEME */ hsl(210 100% 30%);
--color-success: hsl(105 100% 30%);
--color-error: hsl(0 100% 30%);
--border-primary: 0.0625rem solid var(--color-border);
--border-radius: 0.125rem;
@@ -72,15 +78,22 @@
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: hsl(0 0% 15%);
--bg-secondary: hsl(0 0% 20%);
--bg-tertiary: hsl(0 0% 25%);
--bg-primary-h: /* BACKGROUND_COLOR_DARK_THEME_H */ 0;
--bg-primary-s: /* BACKGROUND_COLOR_DARK_THEME_S */ 0%;
--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-invert: black;
--color-accent: hsl(210, 100%, 80%);
--color-success: hsl(105, 100%, 80%);
--color-error: hsl(0, 100%, 80%);
--color-text: hsl(var(--bg-primary-h) var(--bg-primary-s) 100%);
--color-text-invert: var(--bg-primary);
--color-border: hsl(var(--bg-primary-h) var(--bg-primary-s) calc(var(--bg-primary-l) + 50%));
--color-accent: /* ACCENT_COLOR_DARK_THEME */ hsl(210 100% 80%);
--color-success: hsl(105 100% 80%);
--color-error: hsl(0 100% 80%);
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: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click();
await page.getByLabel("Name:").fill("Category-10");
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill("Category-10");
await page.getByLabel("Weight:").click();
await page.getByLabel("Weight:").fill("10");
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("link", { name: "Legal information" }).click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click();
});
@@ -330,22 +330,22 @@ test.describe.serial("Collaborator tests", () => {
test("Create/Update legal information", async ({ page }) => {
await page.getByRole("link", { name: "Blog" }).click();
await page.getByRole("link", { name: "Legal information" }).click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content");
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) {
await expect(page.getByText("Successfully created legal")).toBeVisible();
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
} else {
await expect(page.getByText("Insufficient permissions")).toBeVisible();
}
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").click();
await page.getByPlaceholder("## Impressum\n\n## Privacy policy").fill("## Content updated");
await page.getByLabel("Main content:").click();
await page.getByLabel("Main content:").fill("## Content updated");
await page.getByRole("button", { name: "Submit" }).click();
if (permissionLevel === 30) {
await expect(page.getByText("Successfully updated legal")).toBeVisible();
await expect(page.getByText("Successfully created/updated legal")).toBeVisible();
} else {
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: "Categories" }).click();
await page.getByRole("button", { name: "Create category" }).click();
await page.getByLabel("Name:").click();
await page.getByLabel("Name:").fill(`Category-${permissionLevel}`);
await page.getByLabel("Name:").nth(0).click();
await page.getByLabel("Name:").nth(0).fill(`Category-${permissionLevel}`);
await page.getByRole("spinbutton", { name: "Weight:" }).click();
await page.getByRole("spinbutton", { name: "Weight:" }).fill(permissionLevel.toString());
await page.getByRole("button", { name: "Submit" }).click();

View File

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