custom css works now

This commit is contained in:
Moon.eth
2026-07-29 16:03:12 +09:00
parent f51c576952
commit 81176654ba
11 changed files with 777 additions and 35 deletions
@@ -0,0 +1,158 @@
<script lang="ts">
/**
* Editor for CSS published through the signed-in account's profile fields.
*
* This is deliberately separate from Settings' viewer CSS: saving here is a
* server mutation and changes what every plspace visitor sees on this profile.
*/
import { useAppServices } from '$lib/app-services'
import {
profileFieldLimits,
publishedCssFieldValues,
publishedCssFromFields,
replacePublishedCssFields,
} from '$lib/stores/theme.svelte'
const { endpoints, session } = useAppServices()
let editing = $state(false)
let draft = $state('')
let busy = $state(false)
let error = $state<string | null>(null)
let saved = $state(false)
let locallyPublishedCss = $state<string | null>(null)
const limits = $derived(profileFieldLimits(session.instance))
const currentCss = $derived(
locallyPublishedCss ?? publishedCssFromFields(session.me?.fields),
)
const chunksNeeded = $derived(publishedCssFieldValues(draft, limits.valueLength).length)
function decodeFieldValue(value: string): string {
const container = document.createElement('div')
container.innerHTML = value
return container.textContent ?? ''
}
/** Credential-source fields are raw; fall back to decoding rendered fields. */
function currentFields(): Array<{ name: string; value: string }> {
if (session.me?.source?.fields) {
return session.me.source.fields.map(({ name, value }) => ({ name, value }))
}
return (session.me?.fields ?? []).map(({ name, value }) => ({
name,
value: decodeFieldValue(value),
}))
}
function beginEditing(): void {
draft = currentCss
error = null
saved = false
editing = true
}
async function publish(css: string): Promise<void> {
if (!session.me || busy) return
busy = true
error = null
saved = false
try {
const fields = replacePublishedCssFields(currentFields(), css, limits)
session.me = await endpoints.updateProfileFields(session.api, fields)
locallyPublishedCss = css.trim()
draft = locallyPublishedCss
editing = false
saved = true
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not update your profile CSS.'
} finally {
busy = false
}
}
function remove(): void {
if (!confirm('Remove the CSS published on your profile?')) return
void publish('')
}
</script>
{#if !session.signedIn || !session.me}
<p class="empty-note">
<a href="#/login">Sign in</a> to publish CSS on your profile.
</p>
{:else}
<div class="published-css-editor">
<p class="notice">
<strong>Public profile setting:</strong>
saving here changes your profile for everyone who views it with plspace—not just this
browser. Other clients may display the CSS as ordinary profile fields.
</p>
{#if editing}
<label class="field-label" for="published-css">CSS shown on your profile</label>
<textarea
id="published-css"
class="css-editor"
bind:value={draft}
rows="16"
spellcheck="false"
aria-describedby="published-css-hint"
></textarea>
<p id="published-css-hint" class="field-hint">
{draft.length.toLocaleString()} characters.
{#if chunksNeeded > 1}
plspace will store this in {chunksNeeded} ordered profile fields because
{limits.valueLength.toLocaleString()} characters fit in each field on this server.
{:else}
This fits in one profile field.
{/if}
</p>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
<div class="field-row">
<button
type="button"
class="button button--primary"
disabled={busy || !draft.trim()}
onclick={() => void publish(draft)}
>
{busy ? 'Publishing…' : 'Publish CSS to my profile'}
</button>
<button type="button" class="button" disabled={busy} onclick={() => (editing = false)}>
Cancel
</button>
</div>
{:else}
<p>
{#if currentCss}
Your profile currently publishes
<strong>{currentCss.length.toLocaleString()} characters</strong> of CSS.
{:else}
Your profile does not currently publish any CSS.
{/if}
</p>
{#if error}
<p class="error-note" role="alert">{error}</p>
{:else if saved}
<p class="notice" role="status">Your public profile CSS was updated.</p>
{/if}
<div class="field-row">
<button type="button" class="button button--primary" onclick={beginEditing}>
{currentCss ? 'Edit published CSS' : 'Add CSS to my profile'}
</button>
{#if currentCss}
<button type="button" class="button" disabled={busy} onclick={remove}>
Remove published CSS
</button>
{/if}
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,102 @@
import { fireEvent, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import type { CredentialAccount } from '$lib/api/types'
import { publishedCssFromFields } from '$lib/stores/theme.svelte'
import { account, session, testServices } from '$test/fixtures'
import PublishedCssEditor from './PublishedCssEditor.svelte'
function credential(fields: Array<{ name: string; value: string }>): CredentialAccount {
return {
...account({ fields }),
source: {
note: '',
fields,
privacy: 'public',
sensitive: false,
language: null,
},
}
}
describe('PublishedCssEditor', () => {
it('makes the public effect explicit and publishes without losing other fields', async () => {
const me = credential([{ name: 'Website', value: 'https://example.test' }])
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
credential(fields),
)
const services = testServices({
session: session({
token: 'token',
me,
signedIn: true,
instance: {
title: 'Pleroma',
version: '2.9.0',
configuration: {
accounts: {
max_profile_fields: 10,
max_profile_field_value_length: 50,
},
},
},
}),
endpoints: { updateProfileFields },
})
const view = render(PublishedCssEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.getByText(/everyone who views it with plspace/i)).toBeInTheDocument()
await fireEvent.click(view.getByRole('button', { name: 'Add CSS to my profile' }))
const css = '.one { color: red; }\n.two { color: green; }'
await fireEvent.input(view.getByRole('textbox', { name: 'CSS shown on your profile' }), {
target: { value: css },
})
expect(view.getByText(/store this in 2 ordered profile fields/i)).toBeInTheDocument()
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
const sent = updateProfileFields.mock.calls[0][1]
expect(sent[0]).toEqual({ name: 'Website', value: 'https://example.test' })
expect(sent.slice(1).map((field) => field.name)).toEqual([
'plspace:css1',
'plspace:css2',
])
expect(publishedCssFromFields(sent.slice(1))).toBe(css)
expect(await view.findByText('Your public profile CSS was updated.')).toBeInTheDocument()
})
it('loads and replaces existing CSS chunks', async () => {
const me = credential([
{ name: 'Pronouns', value: 'they/them' },
{ name: 'plspace:css1', value: '.old {' },
{ name: 'plspace:css2', value: ' color: red; }' },
])
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
credential(fields),
)
const services = testServices({
session: session({ token: 'token', me, signedIn: true }),
endpoints: { updateProfileFields },
})
const view = render(PublishedCssEditor, {
context: new Map([[APP_SERVICES, services]]),
})
await fireEvent.click(view.getByRole('button', { name: 'Edit published CSS' }))
const editor = view.getByRole('textbox', { name: 'CSS shown on your profile' })
expect(editor).toHaveValue('.old { color: red; }')
await fireEvent.input(editor, { target: { value: '.new { color: pink; }' } })
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
const sent = updateProfileFields.mock.calls[0][1]
expect(sent[0]).toEqual({ name: 'Pronouns', value: 'they/them' })
expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1'])
expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }')
})
})