implement UI for about page

initial step for #282
pull/325/head
Dario Piotrowicz 2023-02-20 12:56:42 +00:00
rodzic 40d59fbccd
commit 3d0e394a14
10 zmienionych plików z 267 dodań i 17 usunięć

Wyświetl plik

@ -0,0 +1,6 @@
import { adjustLocalHostDomain } from './adjustLocalHostDomain'
export function getDomain(url: URL | string) {
const domain = new URL(url).hostname
return adjustLocalHostDomain(domain)
}

Wyświetl plik

@ -0,0 +1,32 @@
import { component$, Slot, useSignal } from '@builder.io/qwik'
type Props = {
title: string
}
export const Accordion = component$<Props>(({ title }) => {
const headerId = useSignal(
`accordion-${title.replace(/\s/g, '_')}-${`${Math.round(Math.random() * 99999)}`.padStart(5, '0')}`
).value
const expanded = useSignal(false)
return (
<div class="bg-wildebeest-600 border border-wildebeest-700 rounded overflow-hidden">
<header id={headerId} class=" bg-wildebeest-700 text-wildebeest-vibrant-400">
<button
class="py-4 px-5 text-start w-full flex items-center"
onClick$={() => (expanded.value = !expanded.value)}
>
<i class={`fa-solid fa-chevron-${expanded.value ? 'down' : 'right'} mr-3 text-xl`}></i>
<span class="font-semibold">{title}</span>
</button>
</header>
{expanded.value && (
<section aria-labelledby={headerId}>
<Slot />
</section>
)}
</div>
)
})

Wyświetl plik

@ -1,16 +1,16 @@
import { component$ } from '@builder.io/qwik'
import { Link } from '@builder.io/qwik-city'
import { type MastodonStatus } from '~/types'
import { type Account } from '~/types'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { Avatar, type AvatarDetails } from '../avatar'
export const StatusAccountCard = component$<{
status: MastodonStatus
export const AccountCard = component$<{
account: Account
subText: 'username' | 'acct'
secondaryAvatar?: AvatarDetails | null
}>(({ status, subText, secondaryAvatar }) => {
const accountUrl = useAccountUrl(status.account)
}>(({ account, subText, secondaryAvatar }) => {
const accountUrl = useAccountUrl(account)
return (
<Link
@ -18,13 +18,13 @@ export const StatusAccountCard = component$<{
class="inline-grid grid-cols-[repeat(2,_max-content)] grid-rows-[1fr,1fr] items-center no-underline"
>
<div class="row-span-2">
<Avatar primary={status.account} secondary={secondaryAvatar ?? null} />
<Avatar primary={account} secondary={secondaryAvatar ?? null} />
</div>
<div data-testid="account-display-name" class="ml-2 col-start-2 row-start-1">
{getDisplayNameElement(status.account)}
{getDisplayNameElement(account)}
</div>
<div class="ml-2 text-wildebeest-400 col-start-2 row-start-2">
@{subText === 'username' ? status.account.username : status.account.acct}
@{subText === 'username' ? account.username : account.acct}
</div>
</Link>
)

Wyświetl plik

@ -14,6 +14,30 @@
}
}
:global(:is(b, h1, h2, h3, h4, h5, h6)) {
font-weight: 700;
}
:global(h1) {
font-size: 1.5em;
margin-top: 0;
margin-bottom: 1em;
line-height: 1.33;
}
:global(h2) {
font-size: 1.25em;
margin-top: 1.6em;
margin-bottom: 0.6em;
line-height: 1.6;
}
:global(:is(h3, h4, h5, h6)) {
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.5;
}
:global(p) {
margin-bottom: theme('spacing.4');
overflow-wrap: break-word;

Wyświetl plik

@ -5,7 +5,7 @@ import type { Account, MastodonStatus } from '~/types'
import { MediaGallery } from '../MediaGallery.tsx'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { StatusAccountCard } from '../StatusAccountCard/StatusAccountCard'
import { AccountCard } from '../AccountCard/AccountCard'
import { HtmlContent } from '../HtmlContent/HtmlContent'
import { StatusInfoTray } from '../StatusInfoTray/StatusInfoTray'
@ -33,7 +33,7 @@ export default component$((props: Props) => {
<article class="p-4 border-t border-wildebeest-700 break-words">
<RebloggerLink account={reblogger}></RebloggerLink>
<div class="flex justify-between mb-3">
<StatusAccountCard status={status} subText={props.accountSubText} secondaryAvatar={reblogger} />
<AccountCard account={status.account} subText={props.accountSubText} secondaryAvatar={reblogger} />
<Link class="no-underline" href={statusUrl}>
<div class="text-wildebeest-500 flex items-baseline">
<i style={{ height: '0.75rem', width: '0.75rem' }} class="fa fa-xs fa-globe w-3 h-3" />

Wyświetl plik

@ -0,0 +1,142 @@
import { component$ } from '@builder.io/qwik'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { getDomain } from 'wildebeest/backend/src/utils/getDomain'
import { Accordion } from '~/components/Accordion/Accordion'
import { AccountCard } from '~/components/AccountCard/AccountCard'
import { HtmlContent } from '~/components/HtmlContent/HtmlContent'
import { george } from '~/dummyData/accounts'
import { Account } from '~/types'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { instanceLoader } from '../layout'
type AboutInfo = {
image: string
domain: string
contact: {
account: Account
email: string
}
rules: { id: string; text: string }[]
extended_description: {
updated_at: string
content: string
}
}
export const aboutInfoLoader = loader$<Promise<AboutInfo>>(async ({ resolveValue, request, redirect }) => {
// TODO: properly implement loader and remove redirect
throw redirect(302, '/')
const instance = await resolveValue(instanceLoader)
return {
image: instance.thumbnail,
domain: getDomain(request.url),
contact: {
account: george,
email: 'test@test.com',
},
rules: [
{
id: '1',
text: 'Sexually explicit or violent media must be marked as sensitive when posting',
},
{
id: '2',
text: 'No racism, sexism, homophobia, transphobia, xenophobia, or casteism',
},
{
id: '3',
text: 'No incitement of violence or promotion of violent ideologies',
},
{
id: '4',
text: 'No harassment, dogpiling or doxxing of other users',
},
{
id: '7',
text: 'Do not share intentionally false or misleading information',
},
],
extended_description: {
updated_at: '2023-01-19T14:55:44Z',
content:
'<p>Please mind that the <a href="mailto:staff@mastodon.social">staff@mastodon.social</a> e-mail is for inquiries related to the operation of the mastodon.social server specifically. If your account is on another server, <strong>we will not be able to assist you</strong>. For inquiries not related specifically to the operation of this server, such as press inquiries about Mastodon gGmbH, please contact <a href="mailto:press@joinmastodon.org">press@joinmastodon.org</a>. Additional addresses:</p>\n\n<ul>\n<li>Legal, GDPR, DMCA: <a href="mailto:legal@mastodon.social">legal@mastodon.social</a></li>\n<li>Appeals: <a href="mailto:moderation@mastodon.social">moderation@mastodon.social</a></li>\n</ul>\n\n<h2>Funding</h2>\n\n<p>This server is crowdfunded by <a href="https://patreon.com/mastodon">Patreon donations</a>. For a list of sponsors, see <a href="https://joinmastodon.org/sponsors">joinmastodon.org</a>.</p>\n\n<h2>Reporting and moderation</h2>\n\n<p>When reporting accounts, please make sure to include at least a few posts that show rule-breaking behaviour, when applicable. If there is any additional context that might help make a decision, please also include it in the comment. This is especially important when the content is in a language nobody on the moderation team speaks.</p>\n\n<p>We usually handle reports within 24 hours. Please mind that you are not notified when a report you have made has led to a punitive action, and that not all punitive actions are externally visible. For first time offenses, we may opt to delete offending content, escalating to harsher measures on repeat offenses.</p>\n\n<p>We have a team of paid moderators. If you would like to become a moderator, get in touch with us through the e-mail address above.</p>\n\n<h2>Impressum</h2>\n\n<p>Mastodon gGmbH<br>\nMühlenstraße 8a<br>\n14167 Berlin<br>\nGermany</p>\n\n<p>E-Mail-Adresse: hello@joinmastodon.org</p>\n\n<p>Vertretungsberechtigt: Eugen Rochko (Geschäftsführer)</p>\n\n<p>Umsatzsteuer Identifikationsnummer (USt-ID): DE344258260</p>\n\n<p>Handelsregister<br>\nGeführt bei: Amtsgericht Charlottenburg<br>\nNummer: HRB 230086 B</p>\n',
},
}
})
export default component$(() => {
const aboutInfo = aboutInfoLoader().value
return (
<>
<div class="bg-wildebeest-900 sticky top-[3.9rem] xl:top-0 xl:pt-2.5 z-10">
<div class="flex flex-col items-center bg-wildebeest-600 xl:rounded-t overflow-hidden p-5">
<img class="rounded w-full aspect-[1.9] mb-5" src={aboutInfo.image} alt="" />
<h2 data-testid="domain-text" class="my-4 text-2xl font-semibold">
{aboutInfo.domain}
</h2>
<p data-testid="social-text" class="mb-6 text-wildebeest-500">
<span>
Decentralised social media powered by{' '}
<a href="https://joinmastodon.org" class="no-underline text-wildebeest-200 font-semibold" target="_blank">
Mastodon
</a>
</span>
</p>
<div class="rounded bg-wildebeest-700 flex flex-col md:flex-row p-2 w-full my-5" data-testid="contact">
<div class="flex-1 p-4">
<span class="block uppercase text-wildebeest-500 font-semibold mb-5">Administered by:</span>
<AccountCard account={aboutInfo.contact.account} subText="username" />
</div>
<div class="flex-1 p-4 pt-6 md:pt-4 md:pl-6 border-wildebeest-500 border-solid border-t md:border-t-0 md:border-l">
<span class="block uppercase text-wildebeest-500 font-semibold mb-5">Contact:</span>
<span>{aboutInfo.contact.email}</span>
</div>
</div>
<div class="flex flex-col w-full my-5">
<div class="my-1">
<Accordion title="About">
<div class="p-6">
<HtmlContent html={aboutInfo.extended_description.content} />
</div>
</Accordion>
</div>
<div class="my-1">
<Accordion title="Server rules">
<ol class="list-none flex flex-col gap-1 my-5 px-6">
{aboutInfo.rules.map(({ id, text }) => (
<li key={id} class="flex items-center border-wildebeest-700 border-b last-of-type:border-b-0 py-2">
<span class="bg-wildebeest-vibrant-400 text-wildebeest-900 mr-4 my-1 p-4 rounded-full w-5 h-5 grid place-content-center">
{id}
</span>
<span>{text}</span>
</li>
))}
</ol>
</Accordion>
</div>
</div>
</div>
</div>
</>
)
})
export const head: DocumentHead = ({ resolveValue, head }) => {
const instance = resolveValue(instanceLoader)
return getDocumentHead(
{
title: `About - ${instance.title}`,
description: `About page for the ${instance.title} Mastodon instance`,
og: {
type: 'website',
image: instance.thumbnail,
},
},
head
)
}

Wyświetl plik

@ -1,10 +1,7 @@
import { useLocation } from '@builder.io/qwik-city'
import { adjustLocalHostDomain } from 'wildebeest/backend/src/utils/adjustLocalHostDomain'
import { getDomain } from 'wildebeest/backend/src/utils/getDomain'
export const useDomain = () => {
const location = useLocation()
const url = new URL(location.href)
const domain = url.hostname
const adjustedDomain = adjustLocalHostDomain(domain)
return adjustedDomain
return getDomain(location.url)
}

Wyświetl plik

@ -38,8 +38,8 @@
"pages": "NO_D1_WARNING=true wrangler pages",
"database:migrate": "yarn d1 migrations apply DATABASE",
"database:create-mock": "rm -f .wrangler/state/d1/DATABASE.sqlite3 && CI=true yarn database:migrate --local && node ./frontend/mock-db/run.mjs",
"dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_DESCR=My Wildebeest Instance' --live-reload",
"ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_DESCR=My Wildebeest Instance' --compatibility-date=2022-12-20",
"dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' --live-reload",
"ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' --compatibility-date=2022-12-20",
"deploy:init": "yarn pages project create wildebeest && yarn d1 create wildebeest",
"deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest"
},

Wyświetl plik

@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test'
test.skip('Navigation to about page', async () => {
// TODO: Implement after a navigation has been implemented
})
// To update and unskip when we enable the about page
test.skip('View of the about page', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/about')
await expect(page.getByTestId('domain-text')).toHaveText('0.0.0.0')
await expect(page.getByTestId('social-text')).toHaveText('Decentralised social media powered by Mastodon')
await expect(page.getByTestId('contact').getByText('Administered by:George 👍@george')).toBeVisible()
await expect(page.getByTestId('contact').getByText('contact:test@test.com')).toBeVisible()
await expect(page.getByRole('region').filter({ hasText: 'Please mind that the staff' })).not.toBeVisible()
await page.getByRole('button', { name: 'About' }).click()
await expect(page.getByRole('region').filter({ hasText: 'Please mind that the staff' })).toBeVisible()
await page.getByRole('button', { name: 'About' }).click()
await expect(page.getByRole('region').filter({ hasText: 'Please mind that the staff' })).not.toBeVisible()
const getRuleLocator = (ruleId: string) =>
page.getByRole('listitem').filter({ has: page.getByText(ruleId, { exact: true }) })
await expect(getRuleLocator('1')).not.toBeVisible()
await expect(getRuleLocator('2')).not.toBeVisible()
await expect(getRuleLocator('3')).not.toBeVisible()
await page.getByRole('button', { name: 'Server rules' }).click()
await expect(getRuleLocator('1')).toBeVisible()
await expect(getRuleLocator('1')).toContainText(
'Sexually explicit or violent media must be marked as sensitive when posting'
)
await expect(getRuleLocator('2')).toBeVisible()
await expect(getRuleLocator('2')).toContainText('No racism, sexism, homophobia, transphobia, xenophobia, or casteism')
await expect(getRuleLocator('3')).toBeVisible()
await expect(getRuleLocator('3')).toContainText('No incitement of violence or promotion of violent ideologies')
})

Wyświetl plik

@ -71,6 +71,17 @@ test.describe('Presence of appropriate SEO metadata across the application', ()
})
})
// To unskip when we enable the about page
test.skip('in about page', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/about')
await checkPageSeoData(page, {
title: 'About - Test Wildebeest',
description: 'About page for the Test Wildebeest Mastodon instance',
ogType: 'website',
ogImage: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail',
})
})
test('in non-existent page', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/@NonExistent')
await checkPageSeoData(page, {