feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTHOR_SLUG_MAX,
|
||||
SITE_KEY_MAX,
|
||||
SLUG_PATTERN,
|
||||
siteUrlProblem,
|
||||
slugify,
|
||||
suggestFreeSlug,
|
||||
} from '@/lib/directory'
|
||||
import {
|
||||
parseDirectoryQuery,
|
||||
toDirectoryRouteQuery,
|
||||
type DirectoryQuery,
|
||||
} from '@/lib/directoryQuery'
|
||||
|
||||
const ALPHABET = 'abcXYZ019 -_.,é ñ ß æ Ł 日本語 ! /'
|
||||
const SAMPLES = 400
|
||||
|
||||
function seeded(seed: number) {
|
||||
let state = seed
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) % 4294967296
|
||||
return state / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomText(next: () => number, length: number) {
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => [...ALPHABET][Math.floor(next() * [...ALPHABET].length)],
|
||||
).join('')
|
||||
}
|
||||
|
||||
describe('slugify', () => {
|
||||
it('never ends or starts with a dash, matches the slug pattern and respects the maximum', () => {
|
||||
const next = seeded(7)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const max = 1 + Math.floor(next() * 40)
|
||||
const slug = slugify(randomText(next, Math.floor(next() * 80)), max)
|
||||
|
||||
expect(slug.length).toBeLessThanOrEqual(max)
|
||||
expect(slug === '' || SLUG_PATTERN.test(slug)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('trims the dash left by cutting at the maximum', () => {
|
||||
expect(slugify('Journal Renovation', 8)).toBe('journal')
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const next = seeded(11)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const once = slugify(randomText(next, 30), AUTHOR_SLUG_MAX)
|
||||
|
||||
expect(slugify(once, AUTHOR_SLUG_MAX)).toBe(once)
|
||||
}
|
||||
})
|
||||
|
||||
it('folds diacritics and ligatures like the server', () => {
|
||||
expect(slugify('Inès Duarte', 100)).toBe('ines-duarte')
|
||||
expect(slugify('Straße Œuvre', 100)).toBe('strasse-oeuvre')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestFreeSlug', () => {
|
||||
it('returns a free, valid candidate within the maximum for any taken set', () => {
|
||||
const next = seeded(3)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const max = 6 + Math.floor(next() * 30)
|
||||
const slug = slugify(randomText(next, 20), max) || 'a'
|
||||
const taken = new Set<string>()
|
||||
const rounds = Math.floor(next() * 6)
|
||||
for (let round = 0; round < rounds; round += 1) {
|
||||
taken.add(suggestFreeSlug(slug, (candidate) => taken.has(candidate), max))
|
||||
}
|
||||
const candidate = suggestFreeSlug(slug, (value) => taken.has(value), max)
|
||||
|
||||
expect(taken.has(candidate)).toBe(false)
|
||||
expect(candidate.length).toBeLessThanOrEqual(max)
|
||||
expect(SLUG_PATTERN.test(candidate)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a full-length site key within 32 characters', () => {
|
||||
const key = 'a'.repeat(SITE_KEY_MAX)
|
||||
|
||||
expect(suggestFreeSlug(key, () => false, SITE_KEY_MAX)).toBe(`${'a'.repeat(30)}-2`)
|
||||
})
|
||||
|
||||
it('replaces an existing numeric suffix', () => {
|
||||
expect(suggestFreeSlug('journal-2', (value) => value === 'journal-2', 32)).toBe('journal-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('siteUrlProblem', () => {
|
||||
it.each([
|
||||
'https://blog.example.com',
|
||||
'https://blog.example.com/',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:8081',
|
||||
'http://[::1]:5173',
|
||||
'https://localhost',
|
||||
])('accepts %s', (value) => {
|
||||
expect(siteUrlProblem(value)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://user@blog.example.com',
|
||||
'https://user:secret@blog.example.com',
|
||||
'https://:secret@blog.example.com',
|
||||
'http://user@localhost:5173',
|
||||
])('rejects credentials in %s', (value) => {
|
||||
expect(siteUrlProblem(value)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects credentials hidden among random paths and hosts', () => {
|
||||
const next = seeded(5)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const user = randomText(next, 1 + Math.floor(next() * 5)).replace(/[^a-z0-9]/gi, 'u')
|
||||
const problem = siteUrlProblem(`https://${user || 'u'}@host.example`)
|
||||
|
||||
expect(problem).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['http://blog.example.com', 'scheme'],
|
||||
['https://blog.example.com/en', 'path'],
|
||||
['https://blog.example.com?x=1', 'path'],
|
||||
['blog', 'invalid'],
|
||||
['ftp://blog.example.com', 'invalid'],
|
||||
['', 'empty'],
|
||||
])('classifies %s as %s', (value, problem) => {
|
||||
expect(siteUrlProblem(value)).toBe(problem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDirectoryQuery', () => {
|
||||
it('survives a round trip through the route query', () => {
|
||||
const next = seeded(13)
|
||||
const sizes = [5, 25, 50, 100, 30, 0, -1]
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const raw: Record<string, string> = {}
|
||||
if (next() > 0.3) {
|
||||
raw.q = randomText(next, Math.floor(next() * 12))
|
||||
}
|
||||
if (next() > 0.3) {
|
||||
raw.page = String(Math.floor(next() * 12) - 2)
|
||||
}
|
||||
if (next() > 0.3) {
|
||||
raw.size = String(sizes[Math.floor(next() * sizes.length)])
|
||||
}
|
||||
const parsed = parseDirectoryQuery(raw)
|
||||
const again = parseDirectoryQuery(toDirectoryRouteQuery(parsed))
|
||||
|
||||
expect(again).toEqual(parsed)
|
||||
expect(parsed.page).toBeGreaterThanOrEqual(1)
|
||||
expect([5, 25, 50, 100]).toContain(parsed.size)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps other parameters such as edit and drops defaults', () => {
|
||||
const parsed: DirectoryQuery = { q: '', page: 1, size: 25 }
|
||||
|
||||
expect(toDirectoryRouteQuery(parsed, { edit: 'camille', page: '9' })).toEqual({
|
||||
edit: 'camille',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/directoryErrors'
|
||||
|
||||
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
|
||||
['site', 401, undefined, 'session'],
|
||||
['site', 403, 'default_site', 'defaultSite'],
|
||||
['site', 403, 'Only the owner can do this.', 'forbidden'],
|
||||
['site', 404, 'site_not_found', 'gone'],
|
||||
['site', 409, 'site_key_taken', 'keyTaken'],
|
||||
['site', 409, 'site_has_articles', 'hasArticles'],
|
||||
['site', 400, 'invalid_site_url', 'invalidSiteUrl'],
|
||||
['site', 400, 'invalid_locale', 'invalidLocale'],
|
||||
['site', 500, undefined, 'failed'],
|
||||
['author', 403, 'author_rights', 'rights'],
|
||||
['author', 404, 'author_not_found', 'gone'],
|
||||
['author', 409, 'author_slug_taken', 'slugTaken'],
|
||||
['author', 409, 'user_already_linked', 'userLinked'],
|
||||
['author', 409, 'author_has_articles', 'hasArticles'],
|
||||
['author', 422, 'bio_too_long', 'bioTooLong'],
|
||||
['author', 422, 'invalid_url', 'invalidUrl'],
|
||||
['author', 500, undefined, 'failed'],
|
||||
['author', 409, 'something_else', 'failed'],
|
||||
]
|
||||
|
||||
describe('classifyFailure', () => {
|
||||
it.each(CASES)('%s %s %s is %s', (context, status, code, kind) => {
|
||||
expect(classifyFailure(new ApiError(status, undefined, code), context)).toBe(kind)
|
||||
})
|
||||
|
||||
it('treats a network error as a generic failure', () => {
|
||||
expect(classifyFailure(new Error('offline'), 'site')).toBe('failed')
|
||||
})
|
||||
|
||||
it('does not let a site code leak into the author context', () => {
|
||||
expect(classifyFailure(new ApiError(409, undefined, 'site_key_taken'), 'author')).toBe(
|
||||
'failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BIO_MAX,
|
||||
authorSlugProblem,
|
||||
baseUrlProblem,
|
||||
bioProblem,
|
||||
nameProblem,
|
||||
optionalSecureUrlProblem,
|
||||
secureUrlProblem,
|
||||
siteKeyProblem,
|
||||
} from '@/lib/directoryRules'
|
||||
|
||||
describe('directory validation rules', () => {
|
||||
it('asks for a name', () => {
|
||||
expect(nameProblem(' ')).toBe('Enter a name.')
|
||||
expect(nameProblem('Zoe')).toBe('')
|
||||
})
|
||||
|
||||
it('counts the bio in characters, not code units', () => {
|
||||
expect(bioProblem('x'.repeat(BIO_MAX))).toBe('')
|
||||
expect(bioProblem('x'.repeat(BIO_MAX + 1))).toBe('Shorten the bio to 280 characters.')
|
||||
expect(bioProblem('😀'.repeat(BIO_MAX))).toBe('')
|
||||
})
|
||||
|
||||
it('requires https for links and treats an empty website as fine', () => {
|
||||
expect(secureUrlProblem('http://x')).toBe('Enter a full URL starting with https://.')
|
||||
expect(secureUrlProblem('')).toBe('Enter a full URL starting with https://.')
|
||||
expect(secureUrlProblem('https://x.example')).toBe('')
|
||||
expect(optionalSecureUrlProblem('')).toBe('')
|
||||
expect(optionalSecureUrlProblem('example.com')).not.toBe('')
|
||||
})
|
||||
|
||||
it('validates author slugs and site keys', () => {
|
||||
expect(authorSlugProblem('')).toBe('')
|
||||
expect(authorSlugProblem('camille-roche')).toBe('')
|
||||
expect(authorSlugProblem('Camille Roche')).not.toBe('')
|
||||
expect(siteKeyProblem('journal')).toBe('')
|
||||
expect(siteKeyProblem('j')).not.toBe('')
|
||||
expect(siteKeyProblem('a'.repeat(33))).not.toBe('')
|
||||
expect(siteKeyProblem('Journal')).not.toBe('')
|
||||
})
|
||||
|
||||
it('maps a base URL to its message', () => {
|
||||
expect(baseUrlProblem('https://blog.example.com')).toBe('')
|
||||
expect(baseUrlProblem('http://blog.example.com')).toBe(
|
||||
'Use https. For example https://blog.example.com.',
|
||||
)
|
||||
expect(baseUrlProblem('https://u:p@blog.example.com')).toBe(
|
||||
'Enter a full URL, like https://blog.example.com.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
export const AUTHOR_SLUG_MAX = 100
|
||||
export const SITE_KEY_MAX = 32
|
||||
export const SITE_KEY_MIN = 2
|
||||
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
|
||||
|
||||
const LIGATURES: Record<string, string> = {
|
||||
æ: 'ae',
|
||||
œ: 'oe',
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
}
|
||||
const COMBINING_MARKS = /\p{M}/gu
|
||||
const NON_SLUG_RUN = /[^a-z0-9]+/g
|
||||
const EDGE_DASHES = /^-+|-+$/g
|
||||
const TITLE_PREFIX = /^Dr\.\s*/
|
||||
const NUMBERED_SUFFIX = /-(\d+)$/
|
||||
|
||||
export function slugify(text: string, max: number) {
|
||||
const folded = text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(COMBINING_MARKS, '')
|
||||
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
|
||||
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
|
||||
}
|
||||
|
||||
export function suggestFreeSlug(
|
||||
slug: string,
|
||||
isTaken: (candidate: string) => boolean,
|
||||
max: number,
|
||||
) {
|
||||
const base = slug.replace(NUMBERED_SUFFIX, '')
|
||||
let counter = 2
|
||||
for (;;) {
|
||||
const suffix = `-${counter}`
|
||||
const stem = base.slice(0, Math.max(1, max - suffix.length)).replace(EDGE_DASHES, '')
|
||||
const candidate = `${stem}${suffix}`
|
||||
if (!isTaken(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
export function hostOf(url: string) {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function initialsOf(name: string) {
|
||||
return name
|
||||
.replace(TITLE_PREFIX, '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => [...word][0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
export function pluralArticles(count: number) {
|
||||
if (count === 0) {
|
||||
return t('No articles')
|
||||
}
|
||||
return t(count === 1 ? '1 article' : '{0} articles', [count])
|
||||
}
|
||||
|
||||
export type SiteUrlProblem = 'empty' | 'scheme' | 'path' | 'invalid'
|
||||
|
||||
function parseUrl(value: string) {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function siteUrlProblem(value: string): SiteUrlProblem | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return 'empty'
|
||||
}
|
||||
const parsed = parseUrl(trimmed)
|
||||
if (
|
||||
!parsed ||
|
||||
!/^https?:$/.test(parsed.protocol) ||
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
return 'invalid'
|
||||
}
|
||||
const loopback = LOOPBACK_HOSTS.includes(parsed.hostname)
|
||||
if (parsed.protocol === 'http:' && !loopback) {
|
||||
return 'scheme'
|
||||
}
|
||||
if (parsed.pathname.replace(/\/+$/, '') || parsed.search || parsed.hash) {
|
||||
return 'path'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function siteUrlMessage(problem: SiteUrlProblem) {
|
||||
if (problem === 'scheme') {
|
||||
return t('Use https. For example https://blog.example.com.')
|
||||
}
|
||||
if (problem === 'path') {
|
||||
return t('Remove the path. Use only the host, like https://blog.example.com.')
|
||||
}
|
||||
return t('Enter a full URL, like https://blog.example.com.')
|
||||
}
|
||||
|
||||
export function isSecureUrl(value: string) {
|
||||
const parsed = parseUrl(value.trim())
|
||||
return parsed !== null && parsed.protocol === 'https:' && parsed.hostname !== ''
|
||||
}
|
||||
|
||||
export function canonicalPreview(baseUrl: string) {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, '')
|
||||
return `${trimmed}/how-we-price-a-bathroom-renovation`
|
||||
}
|
||||
|
||||
export function characterCount(text: string) {
|
||||
return [...text].length
|
||||
}
|
||||
|
||||
export function reassignQuery(target: number | string | undefined) {
|
||||
return target === undefined ? '' : `?reassign_to=${target}`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
|
||||
export const COPY = {
|
||||
nothingChanged: 'Nothing was changed. Try again in a moment.',
|
||||
siteSaveFailed: "Couldn't save the site. Your changes are still here. Try again in a moment.",
|
||||
authorSaveFailed:
|
||||
"Couldn't save the author. Your changes are still here. Try again in a moment.",
|
||||
defaultSiteBlocked: "The default site can't be deleted. Make another site the default first.",
|
||||
authorSaveFailedTitle: "Couldn't save the author",
|
||||
enterName: 'Enter a name.',
|
||||
shortenBio: 'Shorten the bio to 280 characters.',
|
||||
urlHttps: 'Enter a full URL starting with https://.',
|
||||
userLinked: 'That user already has an author. Choose another user.',
|
||||
loadAuthorsFailed: "Couldn't load authors",
|
||||
checkConnection: 'Check your connection and try again.',
|
||||
} as const satisfies Record<string, MessageKey>
|
||||
|
||||
export function copy(key: keyof typeof COPY) {
|
||||
return t(COPY[key])
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
|
||||
export const SITE_CODES = {
|
||||
notFound: 'site_not_found',
|
||||
keyTaken: 'site_key_taken',
|
||||
hasArticles: 'site_has_articles',
|
||||
defaultSite: 'default_site',
|
||||
invalidUrl: 'invalid_site_url',
|
||||
invalidLocale: 'invalid_locale',
|
||||
} as const
|
||||
|
||||
export const AUTHOR_CODES = {
|
||||
notFound: 'author_not_found',
|
||||
slugTaken: 'author_slug_taken',
|
||||
hasArticles: 'author_has_articles',
|
||||
userLinked: 'user_already_linked',
|
||||
rights: 'author_rights',
|
||||
bioTooLong: 'bio_too_long',
|
||||
invalidUrl: 'invalid_url',
|
||||
userNotFound: 'user_not_found',
|
||||
} as const
|
||||
|
||||
const SESSION_STATUSES = [401]
|
||||
|
||||
export function statusOf(error: unknown) {
|
||||
return error instanceof ApiError ? error.status : 0
|
||||
}
|
||||
|
||||
export function codeOf(error: unknown) {
|
||||
return error instanceof ApiError ? (error.body ?? '').trim() : ''
|
||||
}
|
||||
|
||||
export function isSessionError(error: unknown) {
|
||||
return SESSION_STATUSES.includes(statusOf(error))
|
||||
}
|
||||
|
||||
export type FailureContext = 'site' | 'author'
|
||||
|
||||
export type FailureKind =
|
||||
| 'session'
|
||||
| 'forbidden'
|
||||
| 'defaultSite'
|
||||
| 'rights'
|
||||
| 'gone'
|
||||
| 'keyTaken'
|
||||
| 'invalidSiteUrl'
|
||||
| 'invalidLocale'
|
||||
| 'slugTaken'
|
||||
| 'userLinked'
|
||||
| 'bioTooLong'
|
||||
| 'invalidUrl'
|
||||
| 'hasArticles'
|
||||
| 'failed'
|
||||
|
||||
export function classifyFailure(error: unknown, context: FailureContext): FailureKind {
|
||||
const status = statusOf(error)
|
||||
const code = codeOf(error)
|
||||
if (isSessionError(error)) {
|
||||
return 'session'
|
||||
}
|
||||
if (status === 404) {
|
||||
return 'gone'
|
||||
}
|
||||
if (status === 403) {
|
||||
if (context === 'author') {
|
||||
return 'rights'
|
||||
}
|
||||
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
|
||||
}
|
||||
const table: Record<FailureContext, Record<string, FailureKind>> = {
|
||||
site: {
|
||||
[`409 ${SITE_CODES.keyTaken}`]: 'keyTaken',
|
||||
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
|
||||
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
|
||||
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
|
||||
},
|
||||
author: {
|
||||
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
|
||||
[`409 ${AUTHOR_CODES.userLinked}`]: 'userLinked',
|
||||
[`409 ${AUTHOR_CODES.hasArticles}`]: 'hasArticles',
|
||||
[`422 ${AUTHOR_CODES.bioTooLong}`]: 'bioTooLong',
|
||||
[`422 ${AUTHOR_CODES.invalidUrl}`]: 'invalidUrl',
|
||||
},
|
||||
}
|
||||
return table[context][`${status} ${code}`] ?? 'failed'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface DirectoryMenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
unavailable?: boolean
|
||||
hint?: string
|
||||
separated?: boolean
|
||||
onSelect: (trigger: HTMLElement | null) => void
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
ARTICLE_SEARCH_MAX_LENGTH,
|
||||
DEFAULT_ARTICLE_PAGE_SIZE,
|
||||
} from '@/lib/articleListQuery'
|
||||
|
||||
export interface DirectoryQuery {
|
||||
q: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||
|
||||
function first(value: LocationQuery[string] | undefined) {
|
||||
const single = Array.isArray(value) ? value[0] : value
|
||||
return single ?? ''
|
||||
}
|
||||
|
||||
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
|
||||
const page = first(query.page)
|
||||
const size = Number(first(query.size))
|
||||
return {
|
||||
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
|
||||
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
|
||||
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery = {}) {
|
||||
const route: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (key !== 'q' && key !== 'page' && key !== 'size') {
|
||||
route[key] = first(value)
|
||||
}
|
||||
}
|
||||
if (query.q) {
|
||||
route.q = query.q
|
||||
}
|
||||
if (query.page !== 1) {
|
||||
route.page = String(query.page)
|
||||
}
|
||||
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
|
||||
route.size = String(query.size)
|
||||
}
|
||||
return route
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { t } from '@/i18n'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import {
|
||||
SITE_KEY_MAX,
|
||||
SITE_KEY_MIN,
|
||||
SLUG_PATTERN,
|
||||
characterCount,
|
||||
isSecureUrl,
|
||||
siteUrlMessage,
|
||||
siteUrlProblem,
|
||||
} from '@/lib/directory'
|
||||
|
||||
export const BIO_MAX = 280
|
||||
|
||||
export function nameProblem(name: string) {
|
||||
return name.trim() ? '' : copy('enterName')
|
||||
}
|
||||
|
||||
export function bioProblem(bio: string) {
|
||||
return characterCount(bio) > BIO_MAX ? copy('shortenBio') : ''
|
||||
}
|
||||
|
||||
export function secureUrlProblem(value: string) {
|
||||
return isSecureUrl(value) ? '' : copy('urlHttps')
|
||||
}
|
||||
|
||||
export function optionalSecureUrlProblem(value: string) {
|
||||
return value.trim() ? secureUrlProblem(value) : ''
|
||||
}
|
||||
|
||||
export function authorSlugProblem(slug: string) {
|
||||
if (!slug || SLUG_PATTERN.test(slug)) {
|
||||
return ''
|
||||
}
|
||||
return t('Lowercase letters, numbers and hyphens. Unique across all authors.')
|
||||
}
|
||||
|
||||
export function siteKeyProblem(key: string) {
|
||||
const valid = SLUG_PATTERN.test(key) && key.length >= SITE_KEY_MIN && key.length <= SITE_KEY_MAX
|
||||
return valid ? '' : t('Use lowercase letters, numbers and hyphens, 2 to 32 characters.')
|
||||
}
|
||||
|
||||
export function baseUrlProblem(value: string) {
|
||||
const problem = siteUrlProblem(value)
|
||||
return problem ? siteUrlMessage(problem) : ''
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface LinkableUser {
|
||||
id: number
|
||||
label: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
description: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ImageChoice {
|
||||
title: string
|
||||
description: string
|
||||
hint?: string
|
||||
confirmLabel: string
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const SOCIAL_TYPES = ['x', 'linkedin', 'github', 'mastodon', 'custom'] as const
|
||||
export type SocialType = (typeof SOCIAL_TYPES)[number]
|
||||
|
||||
export const MAX_SOCIAL_LINKS = 5
|
||||
|
||||
export interface SocialLinkRow {
|
||||
key: number
|
||||
type: SocialType
|
||||
url: string
|
||||
}
|
||||
|
||||
export const SOCIAL_PLACEHOLDERS: Record<SocialType, string> = {
|
||||
x: 'https://x.com/username',
|
||||
linkedin: 'https://www.linkedin.com/in/username',
|
||||
github: 'https://github.com/username',
|
||||
mastodon: 'https://mastodon.social/@username',
|
||||
custom: 'https://example.com',
|
||||
}
|
||||
Reference in new issue
Block a user