Release 202506101742

This commit is contained in:
pluja
2025-06-10 17:42:42 +00:00
parent 459d7c91f7
commit 812937d2c7
50 changed files with 1347 additions and 335 deletions

View File

@@ -7,11 +7,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'
<BaseLayout
pageTitle="404: Page Not Found"
description="The page doesn't exist, double check the URL."
className={{
classNames={{
main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]',
footer: 'bg-black',
}}
widthClassName="max-w-none"
isErrorPage
>
<h1
data-text="404"

View File

@@ -1,6 +1,7 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { LOGS_UI_URL } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro'
@@ -21,9 +22,10 @@ const {
<BaseLayout
pageTitle="500: Server Error"
description="Sorry, something crashed on the server."
className={{
classNames={{
main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
}}
isErrorPage
>
<Icon
name="ri:bug-line"
@@ -93,6 +95,20 @@ const {
/>
Contact support
</a>
{
Astro.locals.user?.admin && !!LOGS_UI_URL && (
<a
href={LOGS_UI_URL}
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:menu-search-line"
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
/>
View logs <span class="text-xs text-gray-400">(admin only)</span>
</a>
)
}
</div>
</BaseLayout>

View File

@@ -25,7 +25,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<BaseLayout
pageTitle="403: Access Denied"
description="You don't have permission to access this page."
className={{
classNames={{
main: 'my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50',
}}

View File

@@ -169,7 +169,7 @@ if (!user) return Astro.rewrite('/404')
icon: 'ri:user-3-line',
}}
widthClassName="max-w-screen-md"
className={{
classNames={{
main: 'space-y-6',
}}
breadcrumbs={[

View File

@@ -372,7 +372,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="max-w-full overflow-x-auto">
<div class="min-w-[750px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
@@ -721,51 +721,3 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
</div>
</BaseLayout>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
/* Base CSS text size utility */
.text-2xs {
font-size: 0.6875rem; /* 11px */
line-height: 1rem; /* 16px */
}
/* Scrollbar styling for better mobile experience */
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.6);
}
@media (max-width: 768px) {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
}
</style>

View File

@@ -16,9 +16,6 @@ const releaseDate =
title: 'Releases',
subtitle: 'Current release',
}}
className={{
main: 'flex flex-col items-center justify-center text-center',
}}
>
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
@@ -36,7 +33,7 @@ const releaseDate =
</time>
{
!!releaseDate && (
<p class="text-day-500 mt-2">
<p class="text-day-500 mt-2 text-center">
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p>
)

View File

@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
/>
<InputTextArea
label="ToS URLs"
description="One per line"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',

View File

@@ -120,7 +120,7 @@ if (!user) return Astro.rewrite('/404')
<BaseLayout
pageTitle={`${user.displayName ?? user.name} - User`}
widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }}
classNames={{ main: 'space-y-24' }}
>
<div class="mt-12">
{

View File

@@ -11,7 +11,7 @@ import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { sortAttributes } from '../lib/attributes'
import { nonDbAttributes, sortAttributes } from '../lib/attributes'
import { cn } from '../lib/cn'
import { formatNumber } from '../lib/numbers'
import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -59,9 +59,14 @@ const attributes = await Astro.locals.banners.try(
)
const sortBy = filters['sort-by']
const mergedAttributes = [
...nonDbAttributes.map((attribute) => ({ ...attribute, services: [], id: attribute.slug })),
...attributes,
]
const sortedAttributes = sortBy
? orderBy(
sortAttributes(attributes),
sortAttributes(mergedAttributes),
sortBy === 'type'
? (attribute) => getAttributeTypeInfo(attribute.type).order
: sortBy === 'category'
@@ -73,7 +78,7 @@ const sortedAttributes = sortBy
: 'trustPoints',
filters['sort-order']
)
: sortAttributes(attributes)
: sortAttributes(mergedAttributes)
const attributesWithInfo = sortedAttributes.map((attribute) => ({
...attribute,
@@ -292,7 +297,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<label
for={`show-services-${attribute.id}`}
class="col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180"
class={cn(
'col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180',
attribute.services.length === 0 && 'cursor-default'
)}
aria-label={`Show services for ${attribute.title}`}
>
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
@@ -339,7 +347,9 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
<div class="flex items-center justify-center">
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
{attribute.services.length > 0 && (
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
)}
</div>
</label>

View File

@@ -47,11 +47,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
events: {
some: {
visible: true,
},
},
},
select: {
id: true,
@@ -162,7 +157,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
pageTitle="Events"
description="Discover important events, updates, and news about KYC-free services in chronological order."
widthClassName="max-w-screen-lg"
className={{ main: 'sm:flex sm:items-start sm:gap-6' }}
classNames={{ main: 'sm:flex sm:items-start sm:gap-6' }}
ogImage={{
template: 'generic',
title: 'Events',

View File

@@ -0,0 +1,41 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getEventTypeInfo } from '../../constants/eventTypes'
import { getEvents } from '../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getEvents()
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { events } = result.data
return await rss({
title: 'KYCnot.me - Service Events',
description: 'Latest events and updates from privacy-focused services tracked on KYCnot.me',
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: events.map((event) => {
const eventTypeInfo = getEventTypeInfo(event.type)
const isOngoing = !event.endedAt || event.endedAt > new Date()
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
return {
title: `${event.service.name}: ${event.title}`,
pubDate: event.createdAt,
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
link: `/service/${event.service.slug}/#event-${String(event.id)}`,
categories: [eventTypeInfo.label, event.service.name, statusText],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating events RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,87 @@
---
import { Icon } from 'astro-icon/components'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { takeCounts } from '../../lib/feeds'
const user = Astro.locals.user
const feeds = [
{
title: user ? 'Your notifications' : 'User notifications',
description: `Last ${takeCounts.userNotifications.toLocaleString()} of your notifications`,
rss: user ? `/feeds/user/${user.feedId}/notifications.xml` : '/feeds/user/[feedId]/notifications.xml',
icon: 'ri:notification-line',
replacementDescription: user
? null
: "Replace [feedId] with the user feed ID, found in the user's notifications page",
},
{
title: 'Service comments',
description: `Last ${takeCounts.serviceComments.toLocaleString()} comments (and reviews) from a specific service`,
rss: '/feeds/service/[slug]/comments.xml',
icon: 'ri:chat-3-line',
replacementDescription: 'Replace [slug] with the actual service slug',
},
{
title: 'Service events',
description: `Last ${takeCounts.serviceEvents.toLocaleString()} events from a specific service`,
rss: '/feeds/service/[slug]/events.xml',
icon: 'ri:calendar-event-line',
replacementDescription: 'Replace [slug] with the actual service slug',
},
{
title: 'All events',
description: `Last ${takeCounts.allEvents.toLocaleString()} events from all listed services`,
rss: '/feeds/events.xml',
icon: 'ri:calendar-2-line',
replacementDescription: null,
},
] as const satisfies {
title: string
description: string
rss: `/feeds/${string}`
icon: string
replacementDescription: string | null
}[]
---
<MiniLayout
pageTitle="RSS Feeds"
description="Subscribe to RSS feeds to stay updated with the latest comments and events on KYCnot.me"
ogImage={{
template: 'generic',
title: 'RSS Feeds',
description: 'Subscribe to stay updated',
icon: 'ri:rss-line',
}}
layoutHeader={{
icon: 'ri:rss-line',
title: 'RSS Feeds',
subtitle: 'Copy the feed URL to your RSS reader',
}}
size="md"
>
<div class="space-y-8">
{
feeds.map((feed) => (
<div>
<div class="flex flex-row items-center justify-center gap-2">
<Icon name={feed.icon} class="inline-block size-6 shrink-0 text-white" />
<h2 class="text-left text-lg leading-tight font-bold text-white">{feed.title}</h2>
</div>
<p class="text-day-300 mt-1 text-center text-sm text-balance">{feed.description}</p>
<div
class="border-night-500 bg-night-600 relative mt-2 rounded-lg border px-4 py-2 font-mono break-all text-white"
set:text={`${Astro.url.origin}${feed.rss}`}
/>
{!!feed.replacementDescription && (
<p class="text-day-500 mt-1 text-center text-xs italic">{feed.replacementDescription}</p>
)}
</div>
))
}
</div>
</MiniLayout>

View File

@@ -0,0 +1,61 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getServiceUserRoleInfo } from '../../../../constants/serviceUserRoles'
import { makeCommentUrl } from '../../../../lib/commentsWithReplies'
import { getCommentsForService } from '../../../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getCommentsForService(context.params.slug)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { service, comments } = result.data
return await rss({
title: `${service.name} - Comments & Reviews | KYCnot.me`,
description: `Latest comments and reviews about ${service.name} from KYCnot.me users`,
site: origin,
xmlns: { dc: 'http://purl.org/dc/elements/1.1/', atom: 'http://www.w3.org/2005/Atom' },
items: comments.map((comment) => {
const authorName = comment.author.displayName ?? comment.author.name
const isRating = comment.ratingActive && comment.rating
const title = isRating
? `${authorName} rated ${service.name} (${String(comment.rating)}/5 stars)`
: `${authorName} commented on ${service.name}`
const badges = [
comment.author.verified ? '✅' : null,
comment.author.spammer ? '(Spammer)' : null,
comment.author.admin ? '(Admin)' : null,
comment.author.moderator && !comment.author.admin ? '(Moderator)' : null,
...comment.author.serviceAffiliations.map(
(affiliation) =>
` (${getServiceUserRoleInfo(affiliation.role).label} at ${affiliation.service.name})`
),
].filter((badge) => badge !== null)
return {
title,
pubDate: comment.createdAt,
description: comment.content,
link: makeCommentUrl({
origin,
serviceSlug: service.slug,
commentId: comment.id,
}),
categories: isRating ? ['Rating'] : ['Comment'],
guid: `${service.slug}-comment-${String(comment.id)}`,
customData: `<dc:creator>${authorName}${badges.length > 0 ? ` ${badges.join(' ')}` : ''}</dc:creator>`,
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating service comments RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getEventTypeInfo } from '../../../../constants/eventTypes'
import { getEventsForService } from '../../../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getEventsForService(context.params.slug)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { service, events } = result.data
return await rss({
title: `${service.name} - Events & Updates | KYCnot.me`,
description: `Latest events and updates for ${service.name} tracked on KYCnot.me`,
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: events.map((event) => {
const eventTypeInfo = getEventTypeInfo(event.type)
const isOngoing = !event.endedAt || event.endedAt > new Date()
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
return {
title: `${service.name}: ${event.title}`,
pubDate: event.createdAt,
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
link: `/service/${service.slug}/#event-${String(event.id)}`,
categories: [eventTypeInfo.label, statusText],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating service events RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getNotificationTypeInfo } from '../../../../constants/notificationTypes'
import { getUserNotifications } from '../../../../lib/feeds'
import {
makeNotificationActions,
makeNotificationContent,
makeNotificationTitle,
} from '../../../../lib/notifications'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const feedId = context.params.feedId
const result = await getUserNotifications(feedId)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { user, notifications } = result.data
const displayName = user.displayName ?? user.name
return await rss({
title: `${displayName}'s Notifications - KYCnot.me`,
description: `Notifications for ${displayName} on KYCnot.me - Privacy-focused service reviews and updates`,
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: notifications.map((notification) => {
const typeInfo = getNotificationTypeInfo(notification.type)
const title = makeNotificationTitle(notification, user)
const description = makeNotificationContent(notification) ?? typeInfo.label
const actions = makeNotificationActions(notification, origin)
const link = actions[0]?.url ?? `${origin}/notifications`
return {
title,
pubDate: notification.createdAt,
description,
link,
categories: [typeInfo.label],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating user notifications RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../components/Button.astro'
import CopyButton from '../components/CopyButton.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro'
@@ -84,6 +85,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
aboutServiceSuggestion: {
select: {
status: true,
type: true,
service: {
select: {
name: true,
@@ -256,7 +258,7 @@ const notifications = dbNotifications.map((notification) => ({
label="Reload"
icon="ri:refresh-line"
color="white"
class="ml-auto"
class="no-js:hidden ml-auto"
onclick="window.location.reload()"
/>
</div>
@@ -404,11 +406,73 @@ const notifications = dbNotifications.map((notification) => ({
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div>
</form>
<div
class="relative isolate mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow-sm"
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-zinc-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-zinc-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="mb-4 flex items-center gap-3">
<div class="rounded-md bg-zinc-800 p-2">
<Icon name="ri:rss-line" class="size-6 text-zinc-300" />
</div>
<h3 class="font-title text-xl font-bold text-zinc-200">RSS feeds available</h3>
</div>
<div class="space-y-4">
<div>
<p class="mb-1 text-sm text-zinc-400">
Subscribe to receive your notifications in your favorite RSS reader.
</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
class="flex-1 rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 select-all"
/>
<CopyButton
copyText={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
color="white"
/>
</div>
</div>
<a
href="/feeds"
class="flex items-center justify-between rounded-lg border border-zinc-700/50 bg-zinc-800/30 p-3"
>
<div>
<h4 class="text-sm font-semibold text-zinc-300">Public RSS feeds</h4>
<p class="text-xs text-zinc-500">
Don't require an account to subscribe. Includes service comments and events.
</p>
</div>
<Button
as="span"
label="Browse all"
icon="ri:arrow-right-line"
variant="faded"
color="white"
class="pointer-events-none"
/>
</a>
</div>
</div>
</section>
</BaseLayout>
<script>
document.addEventListener('sse-new-notification', () => {
document.addEventListener('sse:new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
banner.style.display = ''
})

View File

@@ -1,6 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es'
import {
@@ -11,6 +11,7 @@ import {
} from '../../actions/serviceSuggestion'
import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckbox from '../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
@@ -36,7 +37,7 @@ const result = Astro.getActionResult(actions.serviceSuggestion.createService)
if (result && !result.error && !result.data.hasDuplicates) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = result?.error?.code === 'VALIDATION_ERROR' ? result.error.fields : {}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const [categories, attributes] = await Astro.locals.banners.tryMany([
[
@@ -239,7 +240,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<InputTextArea
label="ToS URLs"
description="One per line"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
@@ -349,16 +350,10 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<Captcha action={actions.serviceSuggestion.createService} />
<div>
<div class="flex items-center gap-2 text-lg">
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required />
<label for="rules-confirm">
I understand the
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
</label>
</div>
{inputErrors.rulesConfirm && <p class="mt-1 text-sm text-red-500">{inputErrors.rulesConfirm?.[0]}</p>}
</div>
<InputCheckbox name="rulesConfirm" required error={inputErrors.rulesConfirm}>
I understand the
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
</InputCheckbox>
<InputHoneypotTrap name="message" />

View File

@@ -198,7 +198,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
icon: 'ri:user-3-line',
}}
widthClassName="max-w-screen-md"
className={{
classNames={{
main: 'space-y-6',
}}
htmx