mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
custom css works now
This commit is contained in:
@@ -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; }')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user