Skip to content

Commit c093dc0

Browse files
committed
feat: add experimental theme editor.
1 parent de447b3 commit c093dc0

12 files changed

Lines changed: 388 additions & 33 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"devDependencies": {
1616
"@atproto/api": "^0.13.18",
17+
"@codemirror/lang-html": "^6.4.9",
1718
"@codemirror/lang-markdown": "^6.3.1",
1819
"@codemirror/language": "^6.10.4",
1920
"@dicebear/collection": "^9.2.2",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<script lang="ts">
2+
import type { HTMLAttributes } from 'svelte/elements';
3+
4+
import { minimalSetup, EditorView } from 'codemirror';
5+
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
6+
import { html } from '@codemirror/lang-html';
7+
import { tags as t } from '@lezer/highlight';
8+
9+
let { content = $bindable(), ...attrs }: { content: string } & HTMLAttributes<HTMLDivElement> =
10+
$props();
11+
12+
let editor: EditorView = $state() as any;
13+
14+
let internalContent = $state('');
15+
$effect(() => {
16+
if (internalContent != content && editor) {
17+
editor.update([
18+
editor.state.update({
19+
changes: [{ from: 0, to: editor.state.doc.length, insert: content }]
20+
})
21+
]);
22+
}
23+
});
24+
25+
function editorPlugin(element: HTMLElement) {
26+
editor = new EditorView({
27+
doc: content,
28+
extensions: [
29+
minimalSetup,
30+
html(),
31+
EditorView.lineWrapping,
32+
EditorView.theme({
33+
'&.cm-focused .cm-cursor': {
34+
borderLeftColor: 'white'
35+
}
36+
}),
37+
syntaxHighlighting(
38+
HighlightStyle.define([
39+
{ tag: t.content, color: 'var(--theme-font-color-base)' },
40+
{ tag: t.processingInstruction, color: 'white', fontWeight: 'bold' },
41+
{ tag: t.strong, fontWeight: 'bold', color: 'white' },
42+
{ tag: t.emphasis, fontStyle: 'italic' },
43+
{ tag: t.link, textDecoration: 'underline' },
44+
{ tag: t.url, color: '#BBA2F1' },
45+
{ tag: t.heading, fontWeight: 'bold', textDecoration: 'underline', color: 'white' }
46+
])
47+
)
48+
],
49+
dispatch(tr, view) {
50+
view.update([tr]);
51+
internalContent = view.state.doc.toString();
52+
content = internalContent;
53+
},
54+
parent: element
55+
});
56+
}
57+
</script>
58+
59+
<div use:editorPlugin {...attrs} class="code-editor"></div>
60+
61+
<style>
62+
div:global(.code-editor) {
63+
font-size: 0.8em;
64+
padding: 0.75em;
65+
border: 2px solid white;
66+
border-radius: 1em;
67+
}
68+
div:global(.code-editor .cm-scroller) {
69+
scrollbar-width: thin;
70+
}
71+
div:global(.code-editor .cm-editor) {
72+
border-radius: 1em;
73+
overflow: hidden;
74+
}
75+
div:global(.code-editor .cm-editor.cm-focused) {
76+
outline: none;
77+
}
78+
</style>

src/lib/leaf/profile.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ should be editable by everybody.`)
6767
}
6868
}
6969

70+
export class WeirdTheme extends Component {
71+
value: {
72+
data: Uint8Array;
73+
};
74+
constructor(data: Uint8Array) {
75+
super();
76+
this.value = { data: data };
77+
}
78+
static componentName(): string {
79+
return 'WeirdTheme';
80+
}
81+
static borshSchema(): BorshSchema {
82+
return BorshSchema.Struct({
83+
data: BorshSchema.Vec(BorshSchema.u8)
84+
});
85+
}
86+
static specification(): Component[] {
87+
return [new CommonMark(`The weird theme to render a user's site with.`)];
88+
}
89+
}
90+
7091
export class WeirdWikiRevisionAuthor extends Component {
7192
value: string;
7293
constructor(userId: string) {
@@ -272,6 +293,16 @@ export async function getAvatar(link: ExactLink): Promise<RawImage | undefined>
272293
const ent = await leafClient.get_components(link, RawImage);
273294
return ent?.get(RawImage);
274295
}
296+
export async function setTheme(link: ExactLink, theme: { data: Uint8Array } | undefined) {
297+
await leafClient.update_components(link, [theme ? new WeirdTheme(theme.data) : WeirdTheme]);
298+
}
299+
export async function getTheme(link: ExactLink): Promise<{ data: Uint8Array } | undefined> {
300+
const ent = await leafClient.get_components(link, WeirdTheme);
301+
const comp = ent?.get(WeirdTheme);
302+
if (comp) {
303+
return { data: new Uint8Array(comp.value.data) };
304+
}
305+
}
275306

276307
export async function getAvatarById(rauthyId: string): Promise<RawImage | undefined> {
277308
const id = await profileLinkById(rauthyId);

src/routes/(app)/[username]/+layout.svelte

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -97,51 +97,57 @@
9797
<button class="variant-outline btn" onclick={() => modalStore.trigger(setHandleModal)}>
9898
Change Handle
9999
</button>
100+
<a class="variant-outline btn" href={`/${$page.params.username}/theme-editor`}>
101+
Theme Editor
102+
</a>
100103
<button class="variant-outline btn" onclick={() => modalStore.trigger(deleteProfileModal)}>
101104
Delete Profile
102105
</button>
103106
</div>
104107
</aside>
105108
{/if}
106109

107-
<div class="hidden flex-grow sm:block"></div>
110+
<div class="relative w-full">
111+
<div class="hidden flex-grow sm:block"></div>
108112

109-
<div class="flex max-w-full grow flex-col items-center">
110-
{#if error}
111-
<aside class="alert variant-ghost-error relative mt-8 w-full">
112-
<div class="alert-message">
113-
<p>{error}</p>
114-
</div>
115-
</aside>
116-
{/if}
113+
<div class="flex max-w-full grow flex-col items-center">
114+
{#if error}
115+
<aside class="alert variant-ghost-error relative mt-8 w-full">
116+
<div class="alert-message">
117+
<p>{error}</p>
118+
</div>
119+
</aside>
120+
{/if}
117121

118-
{#if data.pendingDomainVerification}
119-
<aside class="alert variant-ghost-primary relative mt-8 w-full">
120-
<div class="alert-message">
121-
<p>
122-
<strong>Note:&nbsp;</strong>We are currently verifying your domain:
123-
<code>{data.pendingDomainVerification}</code>
124-
</p>
125-
<p>We will automatically update your handle once verification succeeds.</p>
126-
<div class="flex flex-row-reverse">
127-
<button
128-
type="button"
129-
class="variant-ghost-tertiary btn text-sm"
130-
onclick={cancelPendingDomainVerification}>Cancel Verification</button
131-
>
122+
{#if data.pendingDomainVerification}
123+
<aside class="alert variant-ghost-primary relative mt-8 w-full">
124+
<div class="alert-message">
125+
<p>
126+
<strong>Note:&nbsp;</strong>We are currently verifying your domain:
127+
<code>{data.pendingDomainVerification}</code>
128+
</p>
129+
<p>We will automatically update your handle once verification succeeds.</p>
130+
<div class="flex flex-row-reverse">
131+
<button
132+
type="button"
133+
class="variant-ghost-tertiary btn text-sm"
134+
onclick={cancelPendingDomainVerification}>Cancel Verification</button
135+
>
136+
</div>
132137
</div>
133-
</div>
134-
</aside>
135-
{/if}
136-
{@render children()}
137-
</div>
138+
</aside>
139+
{/if}
140+
{@render children()}
141+
</div>
138142

139-
<div class="hidden flex-grow sm:block"></div>
143+
<div class="hidden flex-grow sm:block"></div>
144+
</div>
140145
</div>
141146

142147
<style>
143148
.sidebar {
144149
@apply sticky top-8 mx-4 my-8 flex w-full flex-shrink flex-col rounded-xl border-[1px] border-black bg-pink-300/10 p-5 sm:h-[85vh] sm:w-auto;
150+
flex-basis: 18em;
145151
.btn {
146152
text-wrap: wrap;
147153
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { profileLinkById, setTheme } from '$lib/leaf/profile';
2+
import { getSession } from '$lib/rauthy/server';
3+
import { type RequestHandler, json } from '@sveltejs/kit';
4+
import { z } from 'zod';
5+
6+
const Req = z.object({
7+
template: z.optional(z.string())
8+
});
9+
type Req = z.infer<typeof Req>;
10+
11+
export const POST: RequestHandler = async ({ request, fetch }) => {
12+
const data = await request.json();
13+
const parsed = Req.safeParse(data);
14+
if (!parsed.data) {
15+
return json({ error: `Invalid body ${parsed.error}` }, { status: 400 });
16+
}
17+
18+
const { sessionInfo } = await getSession(fetch, request);
19+
if (!sessionInfo) {
20+
return new Response(null, { status: 403 });
21+
}
22+
const profileLink = await profileLinkById(sessionInfo.user_id);
23+
await setTheme(
24+
profileLink,
25+
parsed.data.template ? { data: new TextEncoder().encode(parsed.data.template) } : undefined
26+
);
27+
28+
return new Response();
29+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getSession } from '$lib/rauthy/server';
2+
import { error } from '@sveltejs/kit';
3+
import type { PageServerLoad } from './$types';
4+
import { getTheme, profileLinkById } from '$lib/leaf/profile';
5+
6+
export const load: PageServerLoad = async ({
7+
fetch,
8+
request
9+
}): Promise<{ theme?: { data: Uint8Array } }> => {
10+
let { sessionInfo } = await getSession(fetch, request);
11+
if (!sessionInfo) return error(403, 'Not logged in');
12+
const profileLink = await profileLinkById(sessionInfo.user_id);
13+
if (!profileLink) return error(404);
14+
15+
return { theme: await getTheme(profileLink) };
16+
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script lang="ts">
2+
import { render as renderProfile } from '$lib/renderer/index';
3+
import TemplateEditor from '$lib/components/editors/TemplateEditor.svelte';
4+
import type { PageData } from './$types';
5+
import { page } from '$app/stores';
6+
import minimalTheme from './minimalTheme.html.j2?raw';
7+
import weirdTheme from '$lib/themes/weird.html.j2?raw';
8+
import { onMount } from 'svelte';
9+
10+
let { data }: { data: PageData } = $props();
11+
12+
const encoder = new TextEncoder();
13+
14+
let template = $state(weirdTheme);
15+
16+
let renderMount: HTMLIFrameElement;
17+
18+
onMount(() => {
19+
if (data.theme) {
20+
template = new TextDecoder().decode(data.theme.data);
21+
}
22+
});
23+
24+
$effect(() => {
25+
const rendered = renderProfile(
26+
{ handle: $page.params.username, ...data.profile, pages: data.pages },
27+
encoder.encode(template)
28+
);
29+
renderMount.contentWindow?.document.open();
30+
renderMount.contentWindow?.document.write(rendered);
31+
renderMount.contentWindow?.document.close();
32+
});
33+
34+
function loadMinimalTheme() {
35+
template = minimalTheme;
36+
}
37+
38+
function resetToDefault() {
39+
template = weirdTheme;
40+
}
41+
42+
async function setUserTheme(template?: string) {
43+
await fetch(`/${$page.params.username}/settings/setTheme`, {
44+
method: 'post',
45+
body: JSON.stringify({
46+
template
47+
}),
48+
headers: [['content-type', 'application/json']]
49+
});
50+
51+
window.location.reload();
52+
}
53+
54+
async function save() {
55+
await setUserTheme(template === weirdTheme ? undefined : template);
56+
}
57+
</script>
58+
59+
<main class="flex w-full flex-col">
60+
<div class="mx-8 flex flex-row items-center justify-between gap-3 pt-4">
61+
<span class="flex-grow"></span>
62+
<h1 class="text-center text-2xl font-bold">Experimental Theme Editor</h1>
63+
<span class="flex-grow"></span>
64+
<span class="flex gap-2">
65+
<button class="variant-ghost btn" onclick={save}>Save</button>
66+
<button class="variant-ghost btn" onclick={resetToDefault}>Reset to Default</button>
67+
<button class="variant-ghost btn" onclick={loadMinimalTheme}
68+
>Load Minimal Example Theme</button
69+
>
70+
</span>
71+
</div>
72+
<div class="flex h-[80vh] w-full flex-grow flex-row items-stretch justify-stretch">
73+
<div class="h-full w-full pr-2 pt-2">
74+
<h2 class="mb-2 text-center text-xl font-bold">HTML Template</h2>
75+
<div class="max-h-full overflow-y-scroll">
76+
<TemplateEditor bind:content={template} class="max-h-full" />
77+
</div>
78+
</div>
79+
<div class="h-full w-full px-2 pt-8">
80+
<iframe
81+
title="Page Preview"
82+
bind:this={renderMount}
83+
height="100%"
84+
width="100%"
85+
style="border: 3px solid white; border-radius: 1em;"
86+
class="bg-white shadow-md"
87+
></iframe>
88+
</div>
89+
</div>
90+
</main>

0 commit comments

Comments
 (0)