mirror of
https://github.com/thiloho/archtika.git
synced 2025-11-22 10:51:36 +01:00
Merge pull request #16 from archtika/devel
Refactor web code, database migrations and set security headers
This commit is contained in:
8
flake.lock
generated
8
flake.lock
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
acmeEmail = "thilo.hohlt@tutanota.com";
|
||||
dnsProvider = "porkbun";
|
||||
dnsEnvironmentFile = /var/lib/porkbun.env;
|
||||
disableRegistration = true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
829
web-app/package-lock.json
generated
829
web-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
72
web-app/src/lib/components/MarkdownEditor.svelte
Normal file
72
web-app/src/lib/components/MarkdownEditor.svelte
Normal 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) +
|
||||
`` +
|
||||
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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
web-app/src/lib/runes.svelte.ts
Normal file
30
web-app/src/lib/runes.svelte.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>`
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) +
|
||||
`` +
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
@@ -36,3 +46,12 @@
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.registration-disabled {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" };
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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" };
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} — {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>
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user