Release 2025-05-19
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
---
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
|
||||
type Props = {
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
//
|
||||
---
|
||||
|
||||
{!!Astro.locals.user?.admin && <slot />}
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
const badge = tv({
|
||||
slots: {
|
||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
||||
icon: 'size-3 shrink-0',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
red: '',
|
||||
orange: '',
|
||||
amber: '',
|
||||
yellow: '',
|
||||
lime: '',
|
||||
green: '',
|
||||
emerald: '',
|
||||
teal: '',
|
||||
cyan: '',
|
||||
sky: '',
|
||||
blue: '',
|
||||
indigo: '',
|
||||
violet: '',
|
||||
purple: '',
|
||||
fuchsia: '',
|
||||
pink: '',
|
||||
rose: '',
|
||||
slate: '',
|
||||
gray: '',
|
||||
zinc: '',
|
||||
neutral: '',
|
||||
stone: '',
|
||||
white: '',
|
||||
black: '',
|
||||
},
|
||||
variant: {
|
||||
solid: '',
|
||||
faded: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Red
|
||||
{ color: 'red', variant: 'solid', class: { base: 'bg-red-500 text-white' } },
|
||||
{ color: 'red', variant: 'faded', class: { base: 'bg-red-500/30 text-red-300' } },
|
||||
// Orange
|
||||
{ color: 'orange', variant: 'solid', class: { base: 'bg-orange-500 text-white' } },
|
||||
{ color: 'orange', variant: 'faded', class: { base: 'bg-orange-500/30 text-orange-300' } },
|
||||
// Amber
|
||||
{ color: 'amber', variant: 'solid', class: { base: 'bg-amber-500 text-black' } },
|
||||
{ color: 'amber', variant: 'faded', class: { base: 'bg-amber-500/30 text-amber-300' } },
|
||||
// Yellow
|
||||
{ color: 'yellow', variant: 'solid', class: { base: 'bg-yellow-500 text-black' } },
|
||||
{ color: 'yellow', variant: 'faded', class: { base: 'bg-yellow-500/30 text-yellow-300' } },
|
||||
// Lime
|
||||
{ color: 'lime', variant: 'solid', class: { base: 'bg-lime-500 text-black' } },
|
||||
{ color: 'lime', variant: 'faded', class: { base: 'bg-lime-500/30 text-lime-300' } },
|
||||
// Green
|
||||
{ color: 'green', variant: 'solid', class: { base: 'bg-green-500 text-black' } },
|
||||
{ color: 'green', variant: 'faded', class: { base: 'bg-green-500/30 text-green-300' } },
|
||||
// Emerald
|
||||
{ color: 'emerald', variant: 'solid', class: { base: 'bg-emerald-500 text-white' } },
|
||||
{ color: 'emerald', variant: 'faded', class: { base: 'bg-emerald-500/30 text-emerald-300' } },
|
||||
// Teal
|
||||
{ color: 'teal', variant: 'solid', class: { base: 'bg-teal-500 text-white' } },
|
||||
{ color: 'teal', variant: 'faded', class: { base: 'bg-teal-500/30 text-teal-300' } },
|
||||
// Cyan
|
||||
{ color: 'cyan', variant: 'solid', class: { base: 'bg-cyan-500 text-white' } },
|
||||
{ color: 'cyan', variant: 'faded', class: { base: 'bg-cyan-500/30 text-cyan-300' } },
|
||||
// Sky
|
||||
{ color: 'sky', variant: 'solid', class: { base: 'bg-sky-500 text-white' } },
|
||||
{ color: 'sky', variant: 'faded', class: { base: 'bg-sky-500/30 text-sky-300' } },
|
||||
// Blue
|
||||
{ color: 'blue', variant: 'solid', class: { base: 'bg-blue-500 text-white' } },
|
||||
{ color: 'blue', variant: 'faded', class: { base: 'bg-blue-500/30 text-blue-300' } },
|
||||
// Indigo
|
||||
{ color: 'indigo', variant: 'solid', class: { base: 'bg-indigo-500 text-white' } },
|
||||
{ color: 'indigo', variant: 'faded', class: { base: 'bg-indigo-500/30 text-indigo-300' } },
|
||||
// Violet
|
||||
{ color: 'violet', variant: 'solid', class: { base: 'bg-violet-500 text-white' } },
|
||||
{ color: 'violet', variant: 'faded', class: { base: 'bg-violet-500/30 text-violet-300' } },
|
||||
// Purple
|
||||
{ color: 'purple', variant: 'solid', class: { base: 'bg-purple-500 text-white' } },
|
||||
{ color: 'purple', variant: 'faded', class: { base: 'bg-purple-500/30 text-purple-300' } },
|
||||
// Fuchsia
|
||||
{ color: 'fuchsia', variant: 'solid', class: { base: 'bg-fuchsia-500 text-white' } },
|
||||
{ color: 'fuchsia', variant: 'faded', class: { base: 'bg-fuchsia-500/30 text-fuchsia-300' } },
|
||||
// Pink
|
||||
{ color: 'pink', variant: 'solid', class: { base: 'bg-pink-500 text-white' } },
|
||||
{ color: 'pink', variant: 'faded', class: { base: 'bg-pink-500/30 text-pink-300' } },
|
||||
// Rose
|
||||
{ color: 'rose', variant: 'solid', class: { base: 'bg-rose-500 text-white' } },
|
||||
{ color: 'rose', variant: 'faded', class: { base: 'bg-rose-500/30 text-rose-300' } },
|
||||
// Slate
|
||||
{ color: 'slate', variant: 'solid', class: { base: 'bg-slate-500 text-white' } },
|
||||
{ color: 'slate', variant: 'faded', class: { base: 'bg-slate-500/30 text-slate-300' } },
|
||||
// Gray
|
||||
{ color: 'gray', variant: 'solid', class: { base: 'bg-gray-500 text-white' } },
|
||||
{ color: 'gray', variant: 'faded', class: { base: 'bg-gray-500/30 text-gray-300' } },
|
||||
// Zinc
|
||||
{ color: 'zinc', variant: 'solid', class: { base: 'bg-zinc-500 text-white' } },
|
||||
{ color: 'zinc', variant: 'faded', class: { base: 'bg-zinc-500/30 text-zinc-300' } },
|
||||
// Neutral
|
||||
{ color: 'neutral', variant: 'solid', class: { base: 'bg-neutral-500 text-white' } },
|
||||
{ color: 'neutral', variant: 'faded', class: { base: 'bg-neutral-500/30 text-neutral-300' } },
|
||||
// Stone
|
||||
{ color: 'stone', variant: 'solid', class: { base: 'bg-stone-500 text-white' } },
|
||||
{ color: 'stone', variant: 'faded', class: { base: 'bg-stone-500/30 text-stone-300' } },
|
||||
// White
|
||||
{ color: 'white', variant: 'solid', class: { base: 'bg-white text-black' } },
|
||||
{ color: 'white', variant: 'faded', class: { base: 'bg-white-500/30 text-white-300' } },
|
||||
// Black
|
||||
{ color: 'black', variant: 'solid', class: { base: 'bg-black text-white' } },
|
||||
{ color: 'black', variant: 'faded', class: { base: 'bg-black-500/30 text-black-300' } },
|
||||
],
|
||||
defaultVariants: {
|
||||
color: 'gray',
|
||||
variant: 'solid',
|
||||
},
|
||||
})
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
icon: iconName,
|
||||
text: textContent,
|
||||
inlineIcon,
|
||||
classNames,
|
||||
|
||||
color,
|
||||
variant,
|
||||
|
||||
class: className,
|
||||
...props
|
||||
} = Astro.props
|
||||
|
||||
const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
---
|
||||
|
||||
<Tag {...props} class={base({ class: className })}>
|
||||
{
|
||||
!!iconName && (
|
||||
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
</Tag>
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||
as: Tag
|
||||
icon: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
}>
|
||||
|
||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
{...divProps}
|
||||
class={cn(
|
||||
'bg-night-900 inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||
<span>{text}</span>
|
||||
</Tag>
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
|
||||
import HtmxScript from './HtmxScript.astro'
|
||||
import { makeOgImageUrl } from './OgImage'
|
||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
import type { WithContext, BreadcrumbList, ListItem } from 'schema-dts'
|
||||
|
||||
export type BreadcrumArray = [
|
||||
...{
|
||||
name: string
|
||||
url: string
|
||||
}[],
|
||||
{
|
||||
name: string
|
||||
url?: string
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
pageTitle: string
|
||||
/**
|
||||
* Whether to enable htmx.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
htmx?: boolean
|
||||
/**
|
||||
* Page meta description
|
||||
*
|
||||
* @default 'KYCnot.me helps you find services without KYC for better privacy and control over your data.'
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Open Graph image.
|
||||
* - If `string` is provided, it will be used as the image URL.
|
||||
* - If `{ template: string, ...props }` is provided, it will be used to generate an Open Graph image based on the template.
|
||||
*/
|
||||
ogImage?: Parameters<typeof makeOgImageUrl>[0]
|
||||
|
||||
schemas?: ComponentProps<typeof Schema>['item'][]
|
||||
|
||||
breadcrumbs?: BreadcrumArray | BreadcrumArray[]
|
||||
}
|
||||
|
||||
const {
|
||||
pageTitle,
|
||||
htmx = false,
|
||||
description = 'KYCnot.me helps you find services without KYC for better privacy and control over your data.',
|
||||
ogImage,
|
||||
schemas,
|
||||
breadcrumbs,
|
||||
} = Astro.props
|
||||
|
||||
const breadcrumbLists = breadcrumbs?.every(Array.isArray)
|
||||
? (breadcrumbs as BreadcrumArray[])
|
||||
: breadcrumbs?.every(isNotArray)
|
||||
? [breadcrumbs]
|
||||
: []
|
||||
|
||||
const modeName = DEPLOYMENT_MODE === 'production' ? '' : DEPLOYMENT_MODE === 'staging' ? 'PRE' : 'DEV'
|
||||
const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
---
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
|
||||
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
|
||||
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{fullTitle}</title>
|
||||
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={fullTitle} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||
|
||||
<!-- Other -->
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<meta name="theme-color" content="#040505" />
|
||||
|
||||
<!-- Components -->
|
||||
<ClientRouter />
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
<!-- JSON-LD Schemas -->
|
||||
{schemas?.map((item) => <Schema item={item} />)}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{
|
||||
breadcrumbLists.map((breadcrumbList) => (
|
||||
<Schema
|
||||
item={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbList.map(
|
||||
(item, index) =>
|
||||
({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url ? new URL(item.url, Astro.url).href : undefined,
|
||||
}) satisfies ListItem
|
||||
),
|
||||
} satisfies WithContext<BreadcrumbList>
|
||||
}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||
VariantProps<typeof button> & {
|
||||
as: Tag
|
||||
label?: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
classNames?: {
|
||||
label?: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
}
|
||||
dataAstroReload?: boolean
|
||||
children?: never
|
||||
disabled?: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export type ButtonProps<Tag extends 'a' | 'button' | 'label' = 'button'> = Props<Tag>
|
||||
|
||||
const button = tv({
|
||||
slots: {
|
||||
base: 'inline-flex items-center justify-center gap-2 rounded-lg border transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:outline-hidden',
|
||||
icon: 'size-4 shrink-0',
|
||||
label: 'text-left whitespace-nowrap',
|
||||
endIcon: 'size-4 shrink-0',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: {
|
||||
base: 'h-8 px-3 text-sm',
|
||||
icon: 'size-4',
|
||||
endIcon: 'size-4',
|
||||
},
|
||||
md: {
|
||||
base: 'h-9 px-4 text-sm',
|
||||
icon: 'size-4',
|
||||
endIcon: 'size-4',
|
||||
label: 'font-medium',
|
||||
},
|
||||
lg: {
|
||||
base: 'h-10 px-5 text-base',
|
||||
icon: 'size-5',
|
||||
endIcon: 'size-5',
|
||||
label: 'font-bold tracking-wider uppercase',
|
||||
},
|
||||
},
|
||||
color: {
|
||||
black: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
white: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
gray: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
success: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
error: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
warning: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
info: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
},
|
||||
shadow: {
|
||||
true: {
|
||||
base: 'shadow-lg',
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
true: {
|
||||
base: 'cursor-not-allowed',
|
||||
},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
color: 'black',
|
||||
shadow: true,
|
||||
class: 'shadow-black/30',
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
shadow: true,
|
||||
class: 'shadow-white/30',
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
shadow: true,
|
||||
class: 'shadow-day-500/30',
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
shadow: true,
|
||||
class: 'shadow-green-500/30',
|
||||
},
|
||||
{
|
||||
color: 'error',
|
||||
shadow: true,
|
||||
class: 'shadow-red-500/30',
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
shadow: true,
|
||||
class: 'shadow-yellow-500/30',
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
shadow: true,
|
||||
class: 'shadow-blue-500/30',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'black',
|
||||
shadow: false,
|
||||
disabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
||||
label,
|
||||
icon,
|
||||
endIcon,
|
||||
size,
|
||||
color,
|
||||
shadow,
|
||||
class: className,
|
||||
classNames,
|
||||
role,
|
||||
dataAstroReload,
|
||||
disabled,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const {
|
||||
base,
|
||||
icon: iconSlot,
|
||||
label: labelSlot,
|
||||
endIcon: endIconSlot,
|
||||
} = button({ size, color, shadow, disabled })
|
||||
|
||||
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
---
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: className })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} />}
|
||||
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
|
||||
{
|
||||
!!endIcon && (
|
||||
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })}>
|
||||
{endIcon}
|
||||
</Icon>
|
||||
)
|
||||
}
|
||||
</ActualTag>
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
type Props<
|
||||
TAccept extends ActionAccept,
|
||||
TInputSchema extends z.ZodType,
|
||||
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
||||
> = HTMLAttributes<'div'> & {
|
||||
action: TAction
|
||||
}
|
||||
|
||||
const { class: className, action: formAction, autofocus, ...htmlProps } = Astro.props
|
||||
|
||||
const result = Astro.getActionResult(formAction)
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const captcha = generateCaptcha()
|
||||
---
|
||||
|
||||
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
|
||||
|
||||
<div {...htmlProps} class={cn('space-y-3', className)}>
|
||||
<p class="sr-only" id="captcha-instructions">
|
||||
This page requires a visual CAPTCHA to ensure you are a human. If you are unable to complete the CAPTCHA,
|
||||
please email us for assistance. <a href="mailto:contact@kycnot.me">contact@kycnot.me</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="@container flex flex-wrap items-center justify-center gap-2"
|
||||
style={{
|
||||
'--img-width': `${captcha.image.width}px`,
|
||||
'--img-height': `${captcha.image.height}px`,
|
||||
'--img-aspect-ratio': `${captcha.image.width} / ${captcha.image.height}`,
|
||||
}}
|
||||
>
|
||||
<label for="captcha-value">
|
||||
<Image {...captcha.image} alt="CAPTCHA verification" class="w-full max-w-(--img-width) rounded" />
|
||||
</label>
|
||||
|
||||
<Icon name="ri:arrow-right-line" class="size-6 text-zinc-600 @max-[calc(144px*2+8px*2+24px)]:hidden" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="captcha-value"
|
||||
name="captcha-value"
|
||||
required
|
||||
class={cn(
|
||||
'aspect-(--img-aspect-ratio) w-full max-w-(--img-width) min-w-0 rounded-md border border-zinc-700 bg-black/20 py-1.5 pl-[0.9em] font-mono text-sm text-zinc-200 uppercase placeholder:text-zinc-600',
|
||||
'pr-0 tracking-[0.9em] transition-colors focus:border-green-500/50 focus:ring-1 focus:ring-green-500/30 focus:outline-none',
|
||||
inputErrors['captcha-value'] && 'border-red-500/50 focus:border-red-500/50 focus:ring-red-500/30'
|
||||
)}
|
||||
autocomplete="off"
|
||||
pattern="[A-Za-z0-9]*"
|
||||
placeholder={'•'.repeat(CAPTCHA_LENGTH)}
|
||||
maxlength={CAPTCHA_LENGTH}
|
||||
aria-describedby="captcha-instructions"
|
||||
autofocus={autofocus}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
inputErrors['captcha-value'] && (
|
||||
<p class="mt-1 text-center text-xs text-red-500">{inputErrors['captcha-value'].join(', ')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<input type="hidden" name="captcha-solution-hash" value={captcha.solutionHash} />
|
||||
</div>
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
import { isInputError } from 'astro:actions'
|
||||
|
||||
import { SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH } from '../actions/serviceSuggestion'
|
||||
import Button from '../components/Button.astro'
|
||||
import Tooltip from '../components/Tooltip.astro'
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import ChatMessages, { type ChatMessage } from './ChatMessages.astro'
|
||||
|
||||
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props<TAction extends AnyAction | undefined = AnyAction | undefined> =
|
||||
HTMLAttributes<'section'> & {
|
||||
messages: ChatMessage[]
|
||||
title?: string
|
||||
userId: number | null
|
||||
action: TAction
|
||||
formData?: TAction extends AnyAction
|
||||
? ActionInputNoFormData<TAction> extends Record<string, unknown>
|
||||
? Omit<ActionInputNoFormData<TAction>, 'content'>
|
||||
: ActionInputNoFormData<TAction>
|
||||
: undefined
|
||||
}
|
||||
|
||||
const { messages, title, userId, action, formData, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const result = action ? Astro.getActionResult(action) : undefined
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
---
|
||||
|
||||
<div class={cn(className)} {...htmlProps}>
|
||||
{!!title && <h3 class="text-day-200 font-title mb-2 text-center text-xl font-bold">{title}</h3>}
|
||||
|
||||
<ChatMessages
|
||||
id="chat-messages"
|
||||
messages={messages}
|
||||
userId={userId}
|
||||
hx-trigger="every 10s"
|
||||
hx-get={Astro.url.pathname}
|
||||
hx-target="#chat-messages"
|
||||
hx-select="#chat-messages>*"
|
||||
/>
|
||||
|
||||
{
|
||||
!!action && (
|
||||
<>
|
||||
<form
|
||||
method="POST"
|
||||
action={action}
|
||||
class="flex items-end gap-2"
|
||||
hx-post={`${Astro.url.pathname}${action}`}
|
||||
hx-target="#chat-messages"
|
||||
hx-select="#chat-messages>*"
|
||||
hx-push-url="true"
|
||||
{...{ 'hx-on::after-request': 'if(event.detail.successful) this.reset()' }}
|
||||
>
|
||||
{typeof formData === 'object' &&
|
||||
formData !== null &&
|
||||
Object.entries(formData).map(([key, value]) => (
|
||||
<input type="hidden" name={key} value={String(value)} />
|
||||
))}
|
||||
<textarea
|
||||
name="content"
|
||||
placeholder="Add a message..."
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.textarea,
|
||||
'max-h-64',
|
||||
!!inputErrors.content && baseInputClassNames.error
|
||||
)}
|
||||
required
|
||||
maxlength={SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<Tooltip text="Send">
|
||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number
|
||||
content: string
|
||||
createdAt: Date
|
||||
user: Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
name: true
|
||||
picture: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
messages: ChatMessage[]
|
||||
userId: number | null
|
||||
}
|
||||
|
||||
const { messages, userId, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'mb-1 flex max-h-[60dvh] flex-col-reverse overflow-y-auto mask-t-from-[calc(100%-var(--spacing)*16)] pt-16',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<p
|
||||
class="sticky bottom-0 -z-1 flex min-h-7 w-full items-end justify-center text-center text-xs text-balance text-gray-500"
|
||||
>
|
||||
<span class="js:hidden">Refresh the page to see new messages</span>
|
||||
<span class="no-js:hidden" data-refresh-in="10">Refreshing every 10s</span>
|
||||
</p>
|
||||
{
|
||||
messages.length > 0 ? (
|
||||
messages
|
||||
.map((message) => ({
|
||||
...message,
|
||||
formattedCreatedAt: formatDateShort(message.createdAt, {
|
||||
prefix: false,
|
||||
hourPrecision: true,
|
||||
caseType: 'sentence',
|
||||
}),
|
||||
}))
|
||||
.map((message, index, messages) => {
|
||||
const isCurrentUser = message.user.id === userId
|
||||
|
||||
const prev = messages[index - 1]
|
||||
const next = messages[index + 1]
|
||||
const isPrevFromSameUser = !!prev && prev.user.id === message.user.id
|
||||
const isPrevSameDate = !!prev && prev.formattedCreatedAt === message.formattedCreatedAt
|
||||
const isNextFromSameUser = !!next && next.user.id === message.user.id
|
||||
const isNextSameDate = !!next && next.formattedCreatedAt === message.formattedCreatedAt
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-col',
|
||||
isCurrentUser ? 'ml-8 items-end' : 'mr-8 items-start',
|
||||
isNextFromSameUser ? 'mt-1' : 'mt-3'
|
||||
)}
|
||||
>
|
||||
{!isCurrentUser && !isNextFromSameUser && (
|
||||
<p class="text-day-500 mb-0.5 text-xs">
|
||||
{!!message.user.picture && (
|
||||
<Picture
|
||||
src={message.user.picture}
|
||||
height={16}
|
||||
width={16}
|
||||
class="inline-block rounded-full align-[-0.33em]"
|
||||
alt=""
|
||||
formats={['jxl', 'avif', 'webp']}
|
||||
/>
|
||||
)}
|
||||
{message.user.name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
class={cn(
|
||||
'rounded-xl p-3 text-sm whitespace-pre-wrap',
|
||||
isCurrentUser ? 'bg-blue-900 text-white' : 'bg-night-500 text-day-300',
|
||||
isCurrentUser ? 'rounded-br-xs' : 'rounded-bl-xs',
|
||||
isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tr-xs',
|
||||
!isCurrentUser && isNextFromSameUser && isNextSameDate && 'rounded-tl-xs'
|
||||
)}
|
||||
id={`message-${message.id.toString()}`}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
{(!isPrevFromSameUser || !isPrevSameDate) && (
|
||||
<p class="text-day-500 mt-0.5 mb-2 text-xs">{message.formattedCreatedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div class="text-day-500 my-16 text-center text-sm italic">No messages yet</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,504 +0,0 @@
|
||||
---
|
||||
import Image from 'astro/components/Image.astro'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { getServiceUserRoleInfo } from '../constants/serviceUserRoles'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
makeCommentUrl,
|
||||
MAX_COMMENT_DEPTH,
|
||||
type CommentWithRepliesPopulated,
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
import CommentReply from './CommentReply.astro'
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
comment: CommentWithRepliesPopulated
|
||||
depth?: number
|
||||
showPending?: boolean
|
||||
highlightedCommentId: number | null
|
||||
serviceSlug: string
|
||||
itemReviewedId: string
|
||||
}
|
||||
|
||||
const {
|
||||
comment,
|
||||
depth = 0,
|
||||
showPending = false,
|
||||
highlightedCommentId = null,
|
||||
serviceSlug,
|
||||
itemReviewedId,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
const user = Astro.locals.user
|
||||
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
const authorUnlocks = computeKarmaUnlocks(comment.author.totalKarma)
|
||||
|
||||
function checkIsHighlightParent(c: CommentWithRepliesPopulated, highlight: number | null): boolean {
|
||||
if (!highlight) return false
|
||||
if (c.id === highlight) return true
|
||||
if (!c.replies?.length) return false
|
||||
return c.replies.some((r) => checkIsHighlightParent(r, highlight))
|
||||
}
|
||||
const isHighlightParent = checkIsHighlightParent(comment, highlightedCommentId)
|
||||
const isHighlighted = comment.id === highlightedCommentId
|
||||
|
||||
// Get user's current vote if any
|
||||
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
||||
|
||||
const isAuthor = user?.id === comment.author.id
|
||||
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
|
||||
|
||||
// Check if user is new (less than 1 week old)
|
||||
const isNewUser =
|
||||
new Date().getTime() - new Date(comment.author.createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
const isRatingActive =
|
||||
comment.rating !== null &&
|
||||
!comment.parentId &&
|
||||
comment.ratingActive &&
|
||||
!comment.suspicious &&
|
||||
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
||||
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
|
||||
const shouldShow =
|
||||
comment.status === 'APPROVED' ||
|
||||
comment.status === 'VERIFIED' ||
|
||||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'PENDING') ||
|
||||
((showPending || isHighlightParent || isHighlighted) && comment.status === 'HUMAN_PENDING') ||
|
||||
((isHighlightParent || isHighlighted) && comment.status === 'REJECTED') ||
|
||||
isAuthorOrPrivileged
|
||||
if (!shouldShow) return null
|
||||
|
||||
const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin: Astro.url.origin })
|
||||
---
|
||||
|
||||
<style>
|
||||
.collapse-toggle:checked + .comment-header .collapse-symbol::before {
|
||||
content: '[+]';
|
||||
}
|
||||
|
||||
.collapse-symbol::before {
|
||||
content: '[-]';
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
id={`comment-${comment.id.toString()}`}
|
||||
class={cn([
|
||||
'group',
|
||||
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
|
||||
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||
'bg-[#182a1f]',
|
||||
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') && 'bg-[#292815]',
|
||||
comment.status === 'REJECTED' && 'bg-[#2f1f1f]',
|
||||
isHighlighted && 'bg-[#192633]',
|
||||
comment.suspicious &&
|
||||
'opacity-25 transition-opacity not-has-[[data-collapse-toggle]:checked]:opacity-100! focus-within:opacity-100 hover:opacity-100 focus:opacity-100',
|
||||
className,
|
||||
])}
|
||||
>
|
||||
{
|
||||
isRatingActive && comment.rating !== null && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
'@id': commentUrl,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: comment.author.displayName ?? comment.author.name,
|
||||
image: comment.author.picture ?? undefined,
|
||||
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
|
||||
},
|
||||
datePublished: comment.createdAt.toISOString(),
|
||||
reviewBody: comment.content,
|
||||
reviewAspect: 'User comment',
|
||||
commentCount: comment.replies?.length ?? 0,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: comment.rating,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`collapse-${comment.id.toString()}`}
|
||||
data-collapse-toggle
|
||||
class="collapse-toggle peer/collapse hidden"
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
</label>
|
||||
|
||||
<span class="flex items-center gap-1">
|
||||
{
|
||||
comment.author.picture && (
|
||||
<Image
|
||||
src={comment.author.picture}
|
||||
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
|
||||
class="size-6 rounded-full bg-zinc-700 object-cover"
|
||||
loading="lazy"
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<a
|
||||
href={`/u/${comment.author.name}`}
|
||||
class={cn([
|
||||
'font-title text-day-300 font-medium hover:underline focus-visible:underline',
|
||||
isAuthor && 'font-medium text-green-500',
|
||||
])}
|
||||
>
|
||||
{comment.author.displayName ?? comment.author.name}
|
||||
</a>
|
||||
|
||||
{
|
||||
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
|
||||
<Tooltip
|
||||
text={`${
|
||||
comment.author.admin || comment.author.verifier
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
>
|
||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
|
||||
{/* User badges - more compact but still with text */}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{
|
||||
comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.author.verifier && !comment.author.admin && (
|
||||
<BadgeSmall icon="ri:shield-check-fill" color="teal" text="Moderator" variant="faded" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isNewUser && !comment.author.admin && !comment.author.verifier && (
|
||||
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
||||
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.highKarmaBadge.icon}
|
||||
color="lime"
|
||||
text="High Karma"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.negativeKarmaBadge && !authorUnlocks.untrustedBadge && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.negativeKarmaBadge.icon}
|
||||
color="orange"
|
||||
text="Negative Karma"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(authorUnlocks.untrustedBadge || comment.author.spammer) && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.untrustedBadge.icon}
|
||||
color="red"
|
||||
text="Untrusted User"
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.author.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
return (
|
||||
<BadgeSmall
|
||||
icon={roleInfo.icon}
|
||||
color={roleInfo.color}
|
||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-1 text-xs text-zinc-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="ri:arrow-up-line" class="size-3" />
|
||||
{comment.upvotes}
|
||||
</span>
|
||||
|
||||
<span class="text-zinc-700">•</span>
|
||||
|
||||
<a href={commentUrl} class="hover:text-zinc-300">
|
||||
<TimeFormatted date={comment.createdAt} hourPrecision />
|
||||
</a>
|
||||
|
||||
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
|
||||
|
||||
{
|
||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.rating !== null && !comment.parentId && (
|
||||
<Tooltip
|
||||
text="Not counting for the total"
|
||||
position="right"
|
||||
enabled={!isRatingActive}
|
||||
class={cn('flex items-center gap-1', isRatingActive ? 'text-yellow-400' : 'text-yellow-400/60')}
|
||||
>
|
||||
<Icon name={isRatingActive ? 'ri:star-fill' : 'ri:star-line'} class="size-3" />
|
||||
{comment.rating.toLocaleString()}/5
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.status === 'VERIFIED' && (
|
||||
<BadgeSmall icon="ri:check-double-fill" color="green" text="Verified" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
(comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING') &&
|
||||
(showPending || isHighlightParent || isAuthorOrPrivileged) && (
|
||||
<BadgeSmall icon="ri:time-fill" color="yellow" text="Unmoderated" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.status === 'REJECTED' && isAuthorOrPrivileged && (
|
||||
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Rejected" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{/* Service usage verification indicators */}
|
||||
{
|
||||
comment.orderId && comment.orderIdStatus === 'APPROVED' && (
|
||||
<BadgeSmall icon="ri:verified-badge-fill" color="green" text="Valid order ID" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.orderId && comment.orderIdStatus === 'REJECTED' && (
|
||||
<BadgeSmall icon="ri:close-circle-fill" color="red" text="Invalid order ID" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.kycRequested && (
|
||||
<BadgeSmall icon="ri:user-forbid-fill" color="red" text="KYC issue" inlineIcon />
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.fundsBlocked && (
|
||||
<BadgeSmall icon="ri:wallet-3-fill" color="orange" text="Funds blocked" inlineIcon />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class={cn(['comment-body mt-2 peer-checked/collapse:hidden'])}>
|
||||
{
|
||||
isAuthor && comment.status === 'REJECTED' && (
|
||||
<div class="mb-2 inline-block rounded-xs bg-red-500/30 px-2 py-1 text-xs text-red-300">
|
||||
This comment has been rejected and is only visible to you
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="text-sm">
|
||||
{
|
||||
!!comment.content && (
|
||||
<div class="prose prose-invert prose-sm max-w-none overflow-auto">
|
||||
<Markdown content={comment.content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
comment.communityNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-zinc-600 bg-zinc-900/30 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-zinc-400">Added context:</span>
|
||||
<span class="text-zinc-300">{comment.communityNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.internalNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-red-400">Internal note:</span>
|
||||
<span class="text-red-300">{comment.internalNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.privateContext && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-blue-400">Private context:</span>
|
||||
<span class="text-blue-300">{comment.privateContext}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mt-2 flex items-center gap-3 text-xs peer-checked/collapse:hidden">
|
||||
<div class="flex items-center gap-1">
|
||||
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="downvote" value="false" />
|
||||
<Tooltip
|
||||
as="button"
|
||||
type="submit"
|
||||
disabled={!user?.totalKarma || user.totalKarma < 20}
|
||||
class={cn([
|
||||
'rounded-sm p-1 hover:bg-zinc-800',
|
||||
userVote?.downvote === false ? 'text-blue-500' : 'text-zinc-500',
|
||||
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
|
||||
])}
|
||||
text={user?.totalKarma && user.totalKarma >= 20 ? 'Upvote' : 'Need 20+ karma to vote'}
|
||||
position="right"
|
||||
aria-label="Upvote"
|
||||
>
|
||||
<Icon name="ri:arrow-up-line" class="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
<form method="POST" action={actions.comment.vote} class="inline" data-astro-reload>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="downvote" value="true" />
|
||||
<Tooltip
|
||||
as="button"
|
||||
text={user?.totalKarma && user.totalKarma >= 20 ? 'Downvote' : 'Need 20+ karma to vote'}
|
||||
position="right"
|
||||
disabled={!user?.totalKarma || user.totalKarma < 20}
|
||||
class={cn([
|
||||
'rounded-sm p-1 hover:bg-zinc-800',
|
||||
userVote?.downvote === true ? 'text-red-500' : 'text-zinc-500',
|
||||
(!user?.totalKarma || user.totalKarma < 20) && 'cursor-not-allowed text-zinc-700',
|
||||
])}
|
||||
aria-label="Downvote"
|
||||
>
|
||||
<Icon name="ri:arrow-down-line" class="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
</div>
|
||||
{
|
||||
user && userCommentsDisabled ? (
|
||||
<span class="text-xs text-red-400">You cannot reply due to low karma.</span>
|
||||
) : (
|
||||
<label
|
||||
for={`reply-toggle-${comment.id.toString()}`}
|
||||
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Icon name="ri:reply-line" class="h-3.5 w-3.5" />
|
||||
Reply
|
||||
</label>
|
||||
)
|
||||
}
|
||||
{
|
||||
user && (
|
||||
<form
|
||||
method="POST"
|
||||
action={`${actions.notification.preferences.watchComment}&comment=${comment.id.toString()}#comment-${comment.id.toString()}`}
|
||||
class="inline"
|
||||
data-astro-reload
|
||||
>
|
||||
<input type="hidden" name="commentId" value={comment.id} />
|
||||
<input type="hidden" name="watch" value={comment.isWatchingReplies ? 'false' : 'true'} />
|
||||
<button
|
||||
type="submit"
|
||||
class="flex cursor-pointer items-center gap-1 text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Icon name={comment.isWatchingReplies ? 'ri:eye-off-line' : 'ri:eye-line'} class="size-3" />
|
||||
{comment.isWatchingReplies ? 'Unwatch' : 'Watch'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<CommentModeration class="mt-2 peer-checked/collapse:hidden" comment={comment} />
|
||||
|
||||
{
|
||||
user && userCommentsDisabled ? null : (
|
||||
<>
|
||||
<input type="checkbox" id={`reply-toggle-${comment.id.toString()}`} class="peer/reply hidden" />
|
||||
<CommentReply
|
||||
serviceId={comment.serviceId}
|
||||
parentId={comment.id}
|
||||
commentId={comment.id}
|
||||
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.replies && comment.replies.length > 0 && depth < MAX_COMMENT_DEPTH && (
|
||||
<div class="replies mt-3 peer-checked/collapse:hidden">
|
||||
{comment.replies.map((reply) => (
|
||||
<Astro.self
|
||||
comment={reply}
|
||||
depth={depth + 1}
|
||||
showPending={showPending}
|
||||
highlightedCommentId={isHighlightParent ? highlightedCommentId : null}
|
||||
serviceSlug={serviceSlug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,366 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
comment: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
status: true
|
||||
suspicious: true
|
||||
requiresAdminReview: true
|
||||
kycRequested: true
|
||||
fundsBlocked: true
|
||||
communityNote: true
|
||||
internalNote: true
|
||||
privateContext: true
|
||||
orderId: true
|
||||
orderIdStatus: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const { comment, class: className, ...divProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
// Only render for admin/verifier users
|
||||
if (!user || !user.admin || !user.verifier) return null
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('text-xs', className)}>
|
||||
<input type="checkbox" id={`mod-toggle-${String(comment.id)}`} class="peer hidden" />
|
||||
<label
|
||||
for={`mod-toggle-${String(comment.id)}`}
|
||||
class="text-day-500 hover:text-day-300 flex cursor-pointer items-center gap-1"
|
||||
>
|
||||
<Icon name="ri:shield-keyhole-line" class="h-3.5 w-3.5" />
|
||||
<span class="text-xs">Moderation</span>
|
||||
<Icon name="ri:arrow-down-s-line" class="h-3.5 w-3.5 transition-transform peer-checked:rotate-180" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="bg-night-600 border-night-500 mt-2 max-h-0 overflow-hidden rounded-md border opacity-0 transition-all duration-200 ease-in-out peer-checked:max-h-[500px] peer-checked:p-2 peer-checked:opacity-100"
|
||||
>
|
||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'REJECTED' ? 'PENDING' : 'REJECTED'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.suspicious
|
||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||
)}
|
||||
data-action="suspicious"
|
||||
data-value={!comment.suspicious}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.requiresAdminReview
|
||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||
)}
|
||||
data-action="requires-admin-review"
|
||||
data-value={!comment.requiresAdminReview}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'VERIFIED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'VERIFIED' ? 'APPROVED' : 'VERIFIED'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.status === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="status"
|
||||
data-value={comment.status === 'PENDING' ? 'APPROVED' : 'PENDING'}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.status === 'PENDING' ? 'Approve' : 'Pending'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.kycRequested
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="kyc-requested"
|
||||
data-value={!comment.kycRequested}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.fundsBlocked
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="funds-blocked"
|
||||
data-value={!comment.fundsBlocked}
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Community:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public note..."
|
||||
value={comment.communityNote}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="community-note"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Internal:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Mod note..."
|
||||
value={comment.internalNote}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="internal-note"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Private:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Context..."
|
||||
value={comment.privateContext}
|
||||
class="bg-night-700 border-night-500 flex-1 rounded-sm border px-1.5 py-0.5 text-xs outline-hidden focus:ring-1 focus:ring-blue-500"
|
||||
data-action="private-context"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
comment.orderId && (
|
||||
<div class="border-night-500 mt-3 space-y-1.5 border-t pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Order ID:</span>
|
||||
<div class="bg-night-700 flex-1 rounded-sm px-1.5 py-0.5 text-xs">{comment.orderId}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-day-500 text-[10px]">Status:</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'APPROVED'
|
||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="APPROVED"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'REJECTED'
|
||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="REJECTED"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||
comment.orderIdStatus === 'PENDING'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||
)}
|
||||
data-action="order-id-status"
|
||||
data-value="PENDING"
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
// Handle button clicks
|
||||
document.querySelectorAll('button[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const action = btn.getAttribute('data-action')
|
||||
const value = btn.getAttribute('data-value')
|
||||
const commentId = parseInt(btn.getAttribute('data-comment-id') || '0')
|
||||
const userId = parseInt(btn.getAttribute('data-user-id') || '0')
|
||||
|
||||
if (!value || !commentId || !userId) return
|
||||
|
||||
try {
|
||||
const { error } = await actions.comment.moderate({
|
||||
commentId,
|
||||
userId,
|
||||
action: action as any,
|
||||
value:
|
||||
action === 'suspicious' ||
|
||||
action === 'requires-admin-review' ||
|
||||
action === 'kyc-requested' ||
|
||||
action === 'funds-blocked'
|
||||
? value === 'true'
|
||||
: value,
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
// Update button state based on new value
|
||||
if (action === 'status') {
|
||||
window.location.reload()
|
||||
} else if (action === 'suspicious') {
|
||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
||||
btn.classList.toggle('bg-yellow-500/20')
|
||||
btn.classList.toggle('text-yellow-400')
|
||||
btn.classList.toggle('border-yellow-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'requires-admin-review') {
|
||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
||||
btn.classList.toggle('bg-purple-500/20')
|
||||
btn.classList.toggle('text-purple-400')
|
||||
btn.classList.toggle('border-purple-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'order-id-status') {
|
||||
// Refresh to show updated order ID status
|
||||
window.location.reload()
|
||||
} else if (action === 'kyc-requested') {
|
||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
} else if (action === 'funds-blocked') {
|
||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||
btn.classList.toggle('bg-red-500/20')
|
||||
btn.classList.toggle('text-red-400')
|
||||
btn.classList.toggle('border-red-500/30')
|
||||
btn.classList.toggle('border')
|
||||
btn.classList.toggle('bg-night-700')
|
||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||
}
|
||||
} else {
|
||||
console.error('Error moderating comment:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moderating comment:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Handle text input changes
|
||||
document.querySelectorAll('input[data-action]').forEach((input) => {
|
||||
const action = input.getAttribute('data-action')
|
||||
const commentId = parseInt(input.getAttribute('data-comment-id') || '0')
|
||||
const userId = parseInt(input.getAttribute('data-user-id') || '0')
|
||||
|
||||
if (!action || !commentId || !userId) return
|
||||
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(async () => {
|
||||
try {
|
||||
const { error } = await actions.comment.moderate({
|
||||
commentId,
|
||||
userId,
|
||||
action: action as any,
|
||||
value: (input as HTMLInputElement).value,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating note:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
}
|
||||
}, 500) // Debounce for 500ms
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import FormTimeTrap from './FormTimeTrap.astro'
|
||||
import InputHoneypotTrap from './InputHoneypotTrap.astro'
|
||||
import InputRating from './InputRating.astro'
|
||||
import InputText from './InputText.astro'
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
||||
serviceId: number
|
||||
parentId?: number
|
||||
commentId?: number
|
||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
rating: true
|
||||
}
|
||||
}> | null
|
||||
}
|
||||
|
||||
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
|
||||
|
||||
const user = Astro.locals.user
|
||||
const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
---
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.comment.create}
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
class={cn(className)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<FormTimeTrap />
|
||||
<input type="hidden" name="serviceId" value={serviceId} />
|
||||
{parentId && <input type="hidden" name="parentId" value={parentId} />}
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`use-form-secret-token-${String(commentId ?? 'new')}`}
|
||||
name="useFormUserSecretToken"
|
||||
checked={!user}
|
||||
class="peer/use-form-secret-token hidden"
|
||||
/>
|
||||
|
||||
{
|
||||
user ? (
|
||||
userCommentsDisabled ? (
|
||||
<div class="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
|
||||
<Icon name="ri:forbid-line" class="mr-1 inline h-4 w-4 align-[-0.2em]" />
|
||||
You cannot comment due to low karma.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="text-day-400 flex items-center gap-2 text-xs peer-checked/use-form-secret-token:hidden">
|
||||
<Icon name="ri:user-line" class="size-3.5" />
|
||||
<span>
|
||||
Commenting as: <span class="font-title font-medium text-green-400">{user.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
id={`comment-${String(commentId ?? 'new')}`}
|
||||
name="content"
|
||||
required
|
||||
minlength={MIN_COMMENT_LENGTH}
|
||||
maxlength={2000}
|
||||
rows="4"
|
||||
placeholder="Write your comment..."
|
||||
class="placeholder:text-day-500 focus:ring-day-500 border-night-500 bg-night-800 focus:border-night-600 max-h-128 min-h-16 w-full resize-y rounded-lg border px-2.5 py-2 text-sm focus:ring-1 focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!parentId ? (
|
||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
|
||||
<InputWrapper label="Tags" name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||
KYC Issue
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||
Funds Blocked
|
||||
</span>
|
||||
</label>
|
||||
</InputWrapper>
|
||||
|
||||
<InputText
|
||||
label="Order ID"
|
||||
name="orderId"
|
||||
inputProps={{
|
||||
maxlength: 100,
|
||||
placeholder: 'Order ID / URL / Proof',
|
||||
class: 'bg-night-800',
|
||||
}}
|
||||
descriptionLabel="Only visible to admins, to verify your comment"
|
||||
class="grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start justify-end gap-2">
|
||||
{!!activeRatingComment?.rating && (
|
||||
<div
|
||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||
data-show-if-rating
|
||||
>
|
||||
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||
<a
|
||||
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||
class="inline-flex items-center gap-1 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Your previous rating
|
||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||
</a>
|
||||
of
|
||||
{[
|
||||
activeRatingComment.rating.toLocaleString(),
|
||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||
]}
|
||||
won't count for the total.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<a
|
||||
href={makeLoginUrl(Astro.url, { message: 'Login to comment' })}
|
||||
data-astro-reload
|
||||
class="font-title mb-4 inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-1.5 text-xs text-blue-400 shadow-xs transition-colors duration-200 hover:bg-blue-500/20 focus:ring-1 focus:ring-blue-500 focus:outline-hidden"
|
||||
>
|
||||
<Icon name="ri:login-box-line" class="size-3.5" />
|
||||
Login to comment
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,263 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { z } from 'zod'
|
||||
|
||||
import CommentItem from '../components/CommentItem.astro'
|
||||
import CommentReply from '../components/CommentReply.astro'
|
||||
import { getCommentStatusInfo } from '../constants/commentStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
commentSortSchema,
|
||||
makeCommentsNestedQuery,
|
||||
MAX_COMMENT_DEPTH,
|
||||
type CommentSortOption,
|
||||
type CommentWithReplies,
|
||||
type CommentWithRepliesPopulated,
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
|
||||
import { makeOgImageUrl } from './OgImage'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Comment, DiscussionForumPosting, WithContext } from 'schema-dts'
|
||||
|
||||
type Props = {
|
||||
itemReviewedId: string
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
slug: true
|
||||
listedAt: true
|
||||
name: true
|
||||
description: true
|
||||
createdAt: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const { service, itemReviewedId } = Astro.props
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
showPending: z.coerce.boolean().default(false),
|
||||
comment: z.coerce.number().int().positive().nullable().default(null),
|
||||
sort: commentSortSchema,
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
const toggleUrl = new URL(Astro.request.url)
|
||||
toggleUrl.hash = '#comments'
|
||||
if (params.showPending) {
|
||||
toggleUrl.searchParams.delete('showPending')
|
||||
} else {
|
||||
toggleUrl.searchParams.set('showPending', 'true')
|
||||
}
|
||||
|
||||
const getSortUrl = (sortOption: CommentSortOption) => {
|
||||
const url = new URL(Astro.request.url)
|
||||
url.searchParams.set('sort', sortOption)
|
||||
return url.toString() + '#comments'
|
||||
}
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Failed to fetch comments',
|
||||
async () =>
|
||||
await prisma.comment.findMany(
|
||||
makeCommentsNestedQuery({
|
||||
depth: MAX_COMMENT_DEPTH,
|
||||
user,
|
||||
showPending: params.showPending,
|
||||
serviceId: service.id,
|
||||
sort: params.sort,
|
||||
})
|
||||
),
|
||||
[],
|
||||
],
|
||||
[
|
||||
'Failed to count unmoderated comments',
|
||||
async () =>
|
||||
prisma.comment.count({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
status: { in: ['PENDING', 'HUMAN_PENDING'] },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
],
|
||||
[
|
||||
"Failed to fetch user's service rating",
|
||||
async () =>
|
||||
user
|
||||
? await prisma.comment.findFirst({
|
||||
where: { serviceId: service.id, authorId: user.id, ratingActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
rating: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
null,
|
||||
],
|
||||
])
|
||||
|
||||
const notiPref = user
|
||||
? await getOrCreateNotificationPreferences(user.id, {
|
||||
watchedComments: { select: { id: true } },
|
||||
})
|
||||
: null
|
||||
|
||||
const populateComment = (comment: CommentWithReplies): CommentWithRepliesPopulated => ({
|
||||
...comment,
|
||||
isWatchingReplies: notiPref?.watchedComments.some((c) => c.id === comment.id) ?? false,
|
||||
replies: comment.replies?.map(populateComment),
|
||||
})
|
||||
const comments = dbComments.map(populateComment)
|
||||
|
||||
function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
const statusInfo = getCommentStatusInfo(comment.status)
|
||||
return {
|
||||
'@type': 'Comment',
|
||||
text: comment.content,
|
||||
datePublished: comment.createdAt.toISOString(),
|
||||
dateCreated: comment.createdAt.toISOString(),
|
||||
creativeWorkStatus: statusInfo.creativeWorkStatus,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: comment.author.displayName ?? comment.author.name,
|
||||
url: new URL(`/u/${comment.author.name}`, Astro.url).href,
|
||||
image: comment.author.picture ?? undefined,
|
||||
},
|
||||
interactionStatistic: [
|
||||
{
|
||||
'@type': 'InteractionCounter',
|
||||
interactionType: { '@type': 'LikeAction' },
|
||||
userInteractionCount: comment.upvotes,
|
||||
},
|
||||
{
|
||||
'@type': 'InteractionCounter',
|
||||
interactionType: { '@type': 'ReplyAction' },
|
||||
userInteractionCount: comment.replies?.length ?? 0,
|
||||
},
|
||||
],
|
||||
commentCount: comment.replies?.length ?? 0,
|
||||
comment: comment.replies?.map(makeReplySchema),
|
||||
} satisfies Comment
|
||||
}
|
||||
---
|
||||
|
||||
<section class="mt-8" id="comments">
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'DiscussionForumPosting',
|
||||
url: new URL(`/service/${service.slug}#comments`, Astro.url).href,
|
||||
mainEntityOfPage: new URL(`/service/${service.slug}#comments`, Astro.url).href,
|
||||
datePublished: service.listedAt?.toISOString(),
|
||||
dateCreated: service.createdAt.toISOString(),
|
||||
headline: `${service.name} comments on KYCnot.me`,
|
||||
text: service.description,
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
image: makeOgImageUrl({ template: 'generic', title: `${service.name} comments` }, Astro.url),
|
||||
|
||||
commentCount: comments.length,
|
||||
comment: comments.map(makeReplySchema),
|
||||
} as WithContext<DiscussionForumPosting>}
|
||||
/>
|
||||
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" />
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-1">
|
||||
<a
|
||||
href={getSortUrl('newest')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'newest'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:time-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Newest
|
||||
</a>
|
||||
<a
|
||||
href={getSortUrl('upvotes')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'upvotes'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:arrow-up-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Most Upvotes
|
||||
</a>
|
||||
{
|
||||
user && (user.admin || user.verifier) && (
|
||||
<a
|
||||
href={getSortUrl('status')}
|
||||
class={cn([
|
||||
'rounded-md px-2 py-1 text-sm',
|
||||
params.sort === 'status'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<Icon name="ri:filter-line" class="mr-1 inline h-3.5 w-3.5" />
|
||||
Status
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{
|
||||
pendingCommentsCount > 0 && (
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href={toggleUrl.toString()}
|
||||
class={cn([
|
||||
'flex items-center gap-2 text-sm',
|
||||
params.showPending ? 'text-yellow-500' : 'text-zinc-400 hover:text-zinc-300',
|
||||
])}
|
||||
>
|
||||
<div class="relative flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-zinc-700 p-1 transition-colors duration-200 ease-in-out focus:outline-hidden">
|
||||
<span
|
||||
class={cn([
|
||||
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-zinc-400 shadow-sm transition-transform duration-200 ease-in-out',
|
||||
params.showPending && 'translate-x-4 bg-yellow-500',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<span>Show unmoderated ({pendingCommentsCount})</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{
|
||||
comments.map((comment) => (
|
||||
<CommentItem
|
||||
comment={comment}
|
||||
highlightedCommentId={params.comment}
|
||||
showPending={params.showPending}
|
||||
serviceSlug={service.slug}
|
||||
itemReviewedId={itemReviewedId}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
import { clamp, round, sum, sumBy } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
serviceId: number
|
||||
itemReviewedId: string
|
||||
averageUserRating?: number | null
|
||||
}
|
||||
|
||||
const {
|
||||
serviceId,
|
||||
itemReviewedId,
|
||||
averageUserRating: averageUserRatingFromProps,
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const ratingsFromDb = await prisma.comment.groupBy({
|
||||
by: ['rating'],
|
||||
where: {
|
||||
serviceId,
|
||||
ratingActive: true,
|
||||
status: {
|
||||
in: ['APPROVED', 'VERIFIED'],
|
||||
},
|
||||
parentId: null,
|
||||
suspicious: false,
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
|
||||
const ratings = ([5, 4, 3, 2, 1] as const).map((rating) => ({
|
||||
rating,
|
||||
count: ratingsFromDb.find((stat) => stat.rating === rating)?._count ?? 0,
|
||||
}))
|
||||
|
||||
const totalComments = sumBy(ratings, 'count')
|
||||
|
||||
const averageUserRatingFromQuery =
|
||||
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
||||
|
||||
if (averageUserRatingFromProps !== undefined) {
|
||||
if (
|
||||
averageUserRatingFromQuery !== averageUserRatingFromProps ||
|
||||
(averageUserRatingFromQuery !== null &&
|
||||
averageUserRatingFromProps !== null &&
|
||||
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
|
||||
) {
|
||||
console.error(
|
||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const averageUserRating =
|
||||
averageUserRatingFromProps === undefined ? averageUserRatingFromQuery : averageUserRatingFromProps
|
||||
---
|
||||
|
||||
<div {...htmlProps} class={cn('flex flex-wrap items-center justify-center gap-4', className)}>
|
||||
{
|
||||
averageUserRating !== null && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AggregateRating',
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
ratingValue: round(averageUserRating, 1),
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
ratingCount: totalComments,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="mb-1 text-5xl">
|
||||
{averageUserRating !== null ? round(averageUserRating, 1).toLocaleString() : '-'}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
{
|
||||
([1, 2, 3, 4, 5] as const).map((rating) => (
|
||||
<div
|
||||
class="relative size-5"
|
||||
style={`--percent: ${clamp((averageUserRating ?? 0) - (rating - 1), 0, 1) * 100}%`}
|
||||
>
|
||||
<Icon name="ri:star-line" class="absolute inset-0 size-full text-zinc-500" />
|
||||
<Icon
|
||||
name="ri:star-fill"
|
||||
class="absolute inset-0 size-full text-yellow-400 [clip-path:inset(0_calc(100%_-_var(--percent))_0_0)]"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-zinc-400">
|
||||
{totalComments.toLocaleString()} ratings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-32 flex-1 grid-cols-[auto_1fr_auto] items-center gap-1">
|
||||
{
|
||||
ratings.map(({ rating, count }) => {
|
||||
const percent = totalComments > 0 ? (count / totalComments) * 100 : null
|
||||
return (
|
||||
<>
|
||||
<div class="text-center text-xs text-zinc-400" aria-label={`${rating} stars`}>
|
||||
{rating.toLocaleString()}
|
||||
</div>
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-zinc-700">
|
||||
<div class="h-full w-(--percent) bg-yellow-400" style={`--percent: ${percent ?? 0}%`} />
|
||||
</div>
|
||||
<div class="text-right text-xs text-zinc-400">
|
||||
{[<span>{round(percent ?? 0).toLocaleString()}</span>, <span class="text-zinc-500">%</span>]}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button, { type ButtonProps } from './Button.astro'
|
||||
|
||||
import type { Optional } from 'ts-toolbelt/out/Object/Optional'
|
||||
|
||||
type Props = Optional<ButtonProps<'button'>, 'icon' | 'label'> & {
|
||||
copyText: string
|
||||
}
|
||||
|
||||
const { copyText, class: className, icon, label, ...buttonProps } = Astro.props
|
||||
---
|
||||
|
||||
<Button
|
||||
data-copy-text={copyText}
|
||||
data-copy-button
|
||||
{...buttonProps}
|
||||
label={label ?? 'Copy'}
|
||||
icon={icon ?? 'ri:clipboard-line'}
|
||||
class={cn(['no-js:hidden', className])}
|
||||
/>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const buttons = document.querySelectorAll<HTMLButtonElement>('[data-copy-button]')
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const text = button.dataset.copyText
|
||||
if (text === undefined) {
|
||||
throw new Error('Copy button must have a data-copy-text attribute')
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
|
||||
const span = button.querySelector<HTMLSpanElement>('span')
|
||||
if (span) {
|
||||
span.textContent = 'Copied'
|
||||
setTimeout(() => {
|
||||
span.textContent = 'Copy'
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
children: AstroChildren
|
||||
label: string
|
||||
icon?: string
|
||||
classNames?: {
|
||||
label?: string
|
||||
icon?: string
|
||||
arrow?: string
|
||||
button?: string
|
||||
}
|
||||
}
|
||||
|
||||
const buttonId = Astro.locals.makeId('dropdown-button')
|
||||
const menuId = Astro.locals.makeId('dropdown-menu')
|
||||
|
||||
const { label, icon, class: className, classNames, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('group/dropdown relative', className)} {...htmlProps}>
|
||||
<Button
|
||||
class={cn(
|
||||
'group-hover/dropdown:bg-night-900 group-focus-within/dropdown:bg-night-900 group-focus-within/dropdown:text-day-200 group-hover/dropdown:text-day-200',
|
||||
classNames?.button
|
||||
)}
|
||||
icon={icon}
|
||||
label={label}
|
||||
endIcon="ri:arrow-down-s-line"
|
||||
classNames={{
|
||||
label: classNames?.label,
|
||||
icon: classNames?.icon,
|
||||
endIcon: cn(
|
||||
'transition-transform group-focus-within/dropdown:rotate-180 group-hover/dropdown:rotate-180',
|
||||
classNames?.arrow
|
||||
),
|
||||
}}
|
||||
aria-haspopup="true"
|
||||
aria-controls={menuId}
|
||||
id={buttonId}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="border-night-500 bg-night-900 absolute right-0 z-50 mt-1 hidden w-48 items-stretch rounded-md border py-1 shadow-lg group-focus-within/dropdown:block group-hover/dropdown:block before:absolute before:-inset-x-px before:bottom-[calc(100%-1*var(--spacing))] before:box-content before:h-2 before:pb-px"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={buttonId}
|
||||
id={menuId}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { ActionInputNoFormData, AnyAction } from '../lib/astroActions'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props<TAction extends AnyAction = AnyAction> = Omit<HTMLAttributes<'form'>, 'action'> & {
|
||||
label: string
|
||||
icon?: string
|
||||
action: TAction
|
||||
data: ActionInputNoFormData<TAction>
|
||||
}
|
||||
|
||||
const { label, icon, action, data, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<form action={action} class={cn('contents', className)} {...htmlProps}>
|
||||
{Object.entries(data).map(([key, value]) => <input type="hidden" name={key} value={String(value)} />)}
|
||||
<button
|
||||
class="text-day-300 hover:bg-night-800 flex w-full items-center px-4 py-2 text-left text-sm hover:text-white"
|
||||
type="submit"
|
||||
>
|
||||
{icon && <Icon name={icon} class="mr-2 size-4" />}
|
||||
<span class="flex-1">{label}</span>
|
||||
<slot name="end" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'a'> & {
|
||||
label: string
|
||||
icon?: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const { label, icon, href, class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class={cn(
|
||||
'text-day-300 hover:bg-night-800 flex items-center px-4 py-2 text-sm hover:text-white',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
{icon && <Icon name={icon} class="mr-2 size-4" />}
|
||||
<span class="flex-1">{label}</span>
|
||||
</a>
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'footer'>
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: SOURCE_CODE_URL,
|
||||
label: 'Source Code',
|
||||
icon: 'ri:git-repository-line',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
icon: 'ri:information-line',
|
||||
external: false,
|
||||
},
|
||||
] as const satisfies {
|
||||
href: string
|
||||
label: string
|
||||
icon: string
|
||||
external: boolean
|
||||
}[]
|
||||
|
||||
const { class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
|
||||
{
|
||||
links.map(
|
||||
({ href, label, icon, external }) =>
|
||||
href && (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
|
||||
>
|
||||
<Icon name={icon} class="h-4 w-4" />
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
)
|
||||
}
|
||||
</footer>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
// Time Trap Component //
|
||||
// This component is used to prevent bots from submitting the form.
|
||||
// It encrypts the current timestamp and stores it in a hidden input field.
|
||||
// The server then decrypts the timestamp and checks if it's valid and
|
||||
// if the time difference is within the allowed range.
|
||||
// If the timestamp is invalid, the form is not submitted.
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
import { timeTrapSecretKey } from '../lib/timeTrapSecret'
|
||||
|
||||
const algorithm = 'aes-256-cbc'
|
||||
const iv = crypto.randomBytes(16) // Generate a random IV for each encryption
|
||||
const timestamp = Date.now().toString()
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
let encrypted = cipher.update(timestamp, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
// Combine IV and encrypted timestamp, then encode as base64 for the input value
|
||||
const encryptedValue = Buffer.from(`${iv.toString('hex')}:${encrypted}`).toString('base64')
|
||||
|
||||
// --- Time Trap Validation Start ---
|
||||
// try {
|
||||
|
||||
// const algorithm = 'aes-256-cbc'
|
||||
// const decodedValue = Buffer.from(input.encTimestamp, 'base64').toString('utf8')
|
||||
// const [ivHex, encryptedHex] = decodedValue.split(':')
|
||||
|
||||
// if (!ivHex || !encryptedHex) {
|
||||
// throw new Error('Invalid time trap format.')
|
||||
// }
|
||||
|
||||
// const iv = Buffer.from(ivHex, 'hex')
|
||||
// const decipher = crypto.createDecipheriv(algorithm, timeTrapSecretKey, iv)
|
||||
// let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
|
||||
// decrypted += decipher.final('utf8')
|
||||
|
||||
// const originalTimestamp = parseInt(decrypted, 10)
|
||||
// if (isNaN(originalTimestamp)) {
|
||||
// throw new Error('Invalid timestamp data.')
|
||||
// }
|
||||
|
||||
// const now = Date.now()
|
||||
// const timeDiff = now - originalTimestamp
|
||||
// const minTimeSeconds = 2 // 2 seconds
|
||||
// const maxTimeMinutes = 60 // 1 hour
|
||||
|
||||
// if (timeDiff < minTimeSeconds * 1000 || timeDiff > maxTimeMinutes * 60 * 1000) {
|
||||
// console.warn(`Time trap triggered: ${timeDiff / 1000}s`)
|
||||
// throw new Error('Invalid submission timing.')
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// console.error('Time trap validation failed:', err.message)
|
||||
// throw new ActionError({
|
||||
// code: 'BAD_REQUEST',
|
||||
// message: 'Invalid request',
|
||||
// })
|
||||
// }
|
||||
// --- Time Trap Validation End ---
|
||||
---
|
||||
|
||||
<input type="hidden" name="encTimestamp" value={encryptedValue} data-time-trap class="hidden" />
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { timeAgo } from '../lib/timeAgo'
|
||||
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'div' | 'li' | 'p' | 'span' = 'span'> = Polymorphic<{
|
||||
as: Tag
|
||||
start: Date
|
||||
end?: Date | null
|
||||
classNames?: {
|
||||
fadedWords?: string
|
||||
}
|
||||
now?: Date
|
||||
}>
|
||||
|
||||
const { start, end = null, classNames = {}, now = new Date(), as: Tag = 'span', ...htmlProps } = Astro.props
|
||||
|
||||
const actualEndedAt = end ?? now
|
||||
const startedAtFormatted = timeAgo.format(start, 'twitter-minute-now')
|
||||
const isUpcoming = now < start
|
||||
const isOngoing = now >= start && (!end || now <= end)
|
||||
const endedAtFormatted = timeAgo.format(actualEndedAt, 'twitter-minute-now')
|
||||
const isOneTimeEvent = start === actualEndedAt || startedAtFormatted === endedAtFormatted
|
||||
---
|
||||
|
||||
<Tag {...htmlProps}>
|
||||
{
|
||||
!end ? (
|
||||
isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Ongoing
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
)
|
||||
) : isOneTimeEvent ? (
|
||||
isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>on</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{isUpcoming ? (
|
||||
<>
|
||||
Upcoming
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>from</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
) : isOngoing ? (
|
||||
<>
|
||||
Ongoing
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>since</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>From</span>
|
||||
<time class="whitespace-nowrap">{startedAtFormatted}</time>
|
||||
<span class={cn('text-current/50', classNames.fadedWords)}>to</span>
|
||||
<time class="whitespace-nowrap">{endedAtFormatted}</time>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
import { cn } from '../lib/cn'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||
|
||||
import AdminOnly from './AdminOnly.astro'
|
||||
import HeaderNotificationIndicator from './HeaderNotificationIndicator.astro'
|
||||
import HeaderSplashTextScript from './HeaderSplashTextScript.astro'
|
||||
import Logo from './Logo.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
const user = Astro.locals.user
|
||||
const actualUser = Astro.locals.actualUser
|
||||
|
||||
type Props = {
|
||||
classNames?: {
|
||||
nav?: string
|
||||
}
|
||||
showSplashText?: boolean
|
||||
}
|
||||
|
||||
const { classNames, showSplashText = false } = Astro.props
|
||||
|
||||
const splashText = showSplashText ? sample(splashTexts) : null
|
||||
---
|
||||
|
||||
<header
|
||||
class={cn(
|
||||
'bg-night-900/80 sticky inset-x-0 top-0 z-50 h-16 border-b border-zinc-800 backdrop-blur-sm [&_~_*_[id]]:scroll-mt-18',
|
||||
{
|
||||
'border-red-900 bg-red-500/60': !!actualUser,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
|
||||
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||
<a href="/" class="relative inline-flex h-full items-center pr-4 pl-4 @[2rem]:pr-0">
|
||||
<Logo
|
||||
class={cn(
|
||||
'h-6 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[192px]:block'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
<Logo
|
||||
variant="small"
|
||||
class={cn(
|
||||
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[94px]:block @[192px]:hidden'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
<Logo
|
||||
variant="mini"
|
||||
class={cn(
|
||||
'font-title h-8 text-green-500 transition-colors hover:text-green-400',
|
||||
{
|
||||
'hue-rotate-227 saturate-800': DEPLOYMENT_MODE === 'development',
|
||||
'hue-rotate-60 saturate-800': DEPLOYMENT_MODE === 'staging',
|
||||
},
|
||||
'hidden @[63px]:block @[94px]:hidden'
|
||||
)}
|
||||
transition:name="header-logo"
|
||||
/>
|
||||
{
|
||||
DEPLOYMENT_MODE !== 'production' && (
|
||||
<span
|
||||
class={cn(
|
||||
'absolute bottom-1 left-9.5 -translate-x-1/2 @[192px]:left-12.5',
|
||||
'text-2xs pointer-events-none hidden rounded-full bg-zinc-800 px-1.25 py-0.75 leading-none font-semibold tracking-wide text-white @[63px]:block',
|
||||
{
|
||||
'border border-red-800 bg-red-950 text-red-400': DEPLOYMENT_MODE === 'development',
|
||||
'border border-cyan-800 bg-cyan-950 text-cyan-400': DEPLOYMENT_MODE === 'staging',
|
||||
}
|
||||
)}
|
||||
transition:name="header-deployment-mode"
|
||||
>
|
||||
{DEPLOYMENT_MODE === 'development' ? 'DEV' : 'PRE'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{
|
||||
!!splashText && (
|
||||
<div
|
||||
class="js:cursor-pointer @container flex min-w-0 flex-1 items-center justify-center"
|
||||
data-splash-text-container
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="font-title line-clamp-2 hidden shrink text-center text-xs text-balance text-lime-500 @[6rem]:inline @4xl:ml-0"
|
||||
data-splash-text
|
||||
>
|
||||
{splashText}
|
||||
</span>
|
||||
<HeaderSplashTextScript />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="flex items-center">
|
||||
<AdminOnly>
|
||||
<Tooltip
|
||||
as="a"
|
||||
href="/admin"
|
||||
class="text-red-500 transition-colors hover:text-red-400"
|
||||
transition:name="header-admin-link"
|
||||
text="Admin Dashboard"
|
||||
position="left"
|
||||
>
|
||||
<Icon name="ri:home-gear-line" class="size-10" />
|
||||
</Tooltip>
|
||||
</AdminOnly>
|
||||
|
||||
{
|
||||
user ? (
|
||||
<>
|
||||
{actualUser && (
|
||||
<span class="text-sm text-white/40 hover:text-white" transition:name="header-actual-user-name">
|
||||
({actualUser.name})
|
||||
</span>
|
||||
)}
|
||||
|
||||
<HeaderNotificationIndicator
|
||||
class="xs:px-3 2xs:px-2 h-full px-1"
|
||||
transition:name="header-notification-indicator"
|
||||
/>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-zinc-400 transition-colors last:-mr-1 hover:text-zinc-300"
|
||||
transition:name="header-user-link"
|
||||
>
|
||||
{user.name}
|
||||
</a>
|
||||
{actualUser ? (
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-unimpersonate-link"
|
||||
aria-label="Unimpersonate"
|
||||
>
|
||||
<Icon name="ri:user-shared-2-line" class="size-4" />
|
||||
</a>
|
||||
) : (
|
||||
DEPLOYMENT_MODE !== 'production' && (
|
||||
<a
|
||||
href="/account/logout"
|
||||
data-astro-prefetch="tap"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-logout-link"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<Icon name="ri:logout-box-r-line" class="size-4" />
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href={makeLoginUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-green-500 transition-colors last:-mr-1 hover:text-green-400"
|
||||
transition:name="header-login-link"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'a'>, 'href'> & {
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
const { count: propsCount, class: className, ...htmlProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
const count =
|
||||
propsCount ??
|
||||
(await Astro.locals.banners.try(
|
||||
'Error getting unread notification count',
|
||||
async () => (user ? await prisma.notification.count({ where: { userId: user.id, read: false } }) : 0),
|
||||
0
|
||||
))
|
||||
---
|
||||
|
||||
{
|
||||
user && (
|
||||
<a
|
||||
href="/notifications"
|
||||
class={cn(
|
||||
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
||||
className
|
||||
)}
|
||||
aria-label={`Go to notifications${count > 0 ? ` (${count} unread)` : ''}`}
|
||||
{...htmlProps}
|
||||
>
|
||||
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
||||
{count > 0 && (
|
||||
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
|
||||
{count > 99 ? '★' : count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////
|
||||
// Optional script to change the splash text on click //
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-splash-text-container]').forEach((container) => {
|
||||
const updateSplashText = () => {
|
||||
const splashTextElem = container.querySelector<HTMLSpanElement>('[data-splash-text]')
|
||||
if (!splashTextElem) return
|
||||
|
||||
const splashTextsFiltered = splashTexts.filter((text) => text !== splashTextElem.textContent)
|
||||
const newSplashText = splashTextsFiltered[Math.floor(Math.random() * splashTextsFiltered.length)]
|
||||
if (!newSplashText) return
|
||||
|
||||
splashTextElem.textContent = newSplashText
|
||||
}
|
||||
|
||||
container.addEventListener('click', updateSplashText)
|
||||
|
||||
const autoUpdateInterval = setInterval(updateSplashText, 60_000)
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
clearInterval(autoUpdateInterval)
|
||||
})
|
||||
|
||||
container.addEventListener(
|
||||
'mousedown',
|
||||
(event) => {
|
||||
if (event.detail > 1) event.preventDefault()
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import * as htmx from 'htmx.org'
|
||||
|
||||
htmx.config.globalViewTransitions = false
|
||||
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
htmx.process(document.body)
|
||||
})
|
||||
|
||||
window.htmx = htmx
|
||||
</script>
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { MarkdownString } from '../lib/markdown'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
iconClass?: string
|
||||
description?: MarkdownString
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValue?: string
|
||||
cardSize?: 'lg' | 'md' | 'sm'
|
||||
iconSize?: 'md' | 'sm'
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
options,
|
||||
disabled,
|
||||
selectedValue,
|
||||
cardSize = 'sm',
|
||||
iconSize = 'sm',
|
||||
class: className,
|
||||
multiple,
|
||||
...wrapperProps
|
||||
} = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} class={cn('@container', className)} {...wrapperProps}>
|
||||
<div
|
||||
class={cn(
|
||||
'grid grid-cols-[repeat(auto-fill,minmax(var(--card-min-size),1fr))] gap-2 rounded-lg',
|
||||
!multiple &&
|
||||
'has-focus-visible:ring-offset-night-900 has-focus-visible:ring-day-200 has-focus-visible:bg-night-900 has-focus-visible:ring-2 has-focus-visible:ring-offset-3',
|
||||
{
|
||||
'[--card-min-size:12rem] @max-[12rem]:grid-cols-1': cardSize === 'sm',
|
||||
'[--card-min-size:16rem] @max-[16rem]:grid-cols-1': cardSize === 'md',
|
||||
'[--card-min-size:32rem] @max-[32rem]:grid-cols-1': cardSize === 'lg',
|
||||
},
|
||||
hasError && 'border border-red-700 p-2'
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label
|
||||
class={cn(
|
||||
'group border-night-400 bg-night-600 hover:bg-night-500 relative cursor-pointer items-start gap-3 rounded-lg border p-3 transition-all',
|
||||
'has-checked:border-green-700 has-checked:bg-green-700/20 has-checked:ring-1 has-checked:ring-green-700',
|
||||
multiple &&
|
||||
'has-focus-visible:border-day-300 has-focus-visible:ring-2 has-focus-visible:ring-green-700 has-focus-visible:ring-offset-1',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
class="peer sr-only"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{option.icon && (
|
||||
<Icon
|
||||
name={option.icon}
|
||||
class={cn(
|
||||
'text-day-200 group-peer-checked:text-day-300 size-8',
|
||||
{
|
||||
'size-4': iconSize === 'sm',
|
||||
'size-8': iconSize === 'md',
|
||||
},
|
||||
option.iconClass
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p class="text-day-200 group-peer-checked:text-day-300 flex-1 text-sm leading-none font-medium text-pretty">
|
||||
{option.label}
|
||||
</p>
|
||||
<div class="self-stretch">
|
||||
<div
|
||||
class={cn(
|
||||
'border-day-600 flex size-5 items-center justify-center border-2',
|
||||
'group-has-checked:border-green-600 group-has-checked:bg-green-600',
|
||||
multiple ? 'rounded-md' : 'rounded-full',
|
||||
!!option.description && '-m-1'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name="ri:check-line"
|
||||
class="text-day-100 size-3 opacity-0 group-has-checked:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div class="prose prose-sm prose-invert text-day-400 mt-1 text-xs text-pretty">
|
||||
<Markdown content={option.description} />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValues?: string[]
|
||||
}
|
||||
|
||||
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
||||
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
|
||||
{
|
||||
options.map((option) => (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{option.icon && <Icon name={option.icon} class="size-4" />}
|
||||
<span class="text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const { accept, disabled, multiple, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<input
|
||||
transition:persist
|
||||
type="file"
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.file,
|
||||
hasError && baseInputClassNames.error,
|
||||
disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
required={wrapperProps.required}
|
||||
disabled={disabled}
|
||||
name={wrapperProps.name}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</InputWrapper>
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
type Props = {
|
||||
name: string
|
||||
}
|
||||
|
||||
//
|
||||
---
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name={Astro.props.name || 'message'}
|
||||
aria-hidden="true"
|
||||
style="display:none !important"
|
||||
autocomplete="off"
|
||||
tabindex="-1"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
data-form-type="other"
|
||||
/>
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import InputFile from './InputFile.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
}
|
||||
|
||||
const { class: className, square, ...inputFileProps } = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||
<InputFile accept={ACCEPTED_IMAGE_TYPES.join(',')} class="min-w-0 flex-1 basis-2xs" {...inputFileProps} />
|
||||
<img
|
||||
src="#"
|
||||
alt="Preview"
|
||||
class={cn(
|
||||
'block w-26.5 rounded object-cover',
|
||||
'no-js:hidden [&[src="#"]]:hidden',
|
||||
square && 'aspect-square'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for image preview. //
|
||||
// Shows a preview of the selected image before upload. //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll('[data-preview-image]').forEach((wrapper) => {
|
||||
const input = wrapper.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
if (!input) return
|
||||
|
||||
const previewImageElements = wrapper.querySelectorAll<HTMLImageElement>('img')
|
||||
if (!previewImageElements.length) return
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const fileUrl = URL.createObjectURL(file)
|
||||
previewImageElements.forEach((previewImage) => {
|
||||
previewImage.src = fileUrl
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { includeDevUsers, USER_SECRET_TOKEN_REGEX_STRING } from '../lib/userSecretToken'
|
||||
|
||||
import InputText from './InputText.astro'
|
||||
|
||||
type Props = {
|
||||
name: string
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const { name, autofocus } = Astro.props
|
||||
---
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
id="username"
|
||||
value=""
|
||||
autocomplete="username"
|
||||
data-keep-in-sync-with="#token"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Login Key"
|
||||
name={name}
|
||||
inputIcon="ri:key-2-line"
|
||||
inputIconClass="size-6"
|
||||
inputProps={{
|
||||
type: 'password',
|
||||
id: 'token',
|
||||
placeholder: 'ABCD-EFGH-IJKL-MNOP-1234',
|
||||
required: true,
|
||||
autofocus,
|
||||
pattern: USER_SECRET_TOKEN_REGEX_STRING,
|
||||
title: 'LLLL-LLLL-LLLL-LLLL-DDDD (L: letter, D: digit, dashes are optional)',
|
||||
minlength: includeDevUsers ? undefined : 24,
|
||||
maxlength: 24,
|
||||
autocomplete: 'current-password',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
autocapitalize: 'off',
|
||||
class: cn('2xs:text-lg h-10 font-mono text-sm uppercase'),
|
||||
'data-input-type-text-hack': true,
|
||||
'data-enable-token-autoformat': true,
|
||||
'data-bwautofill': true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for keeping username in sync. //
|
||||
// This way the password manager detects the credentials. //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-keep-in-sync-with]')
|
||||
inputs.forEach((input) => {
|
||||
const inputId = input.getAttribute('data-keep-in-sync-with')
|
||||
if (!inputId) throw new Error('Username input ID not found')
|
||||
|
||||
const tokenInput = document.querySelector<HTMLInputElement>(inputId)
|
||||
if (!tokenInput) throw new Error('Token input not found')
|
||||
|
||||
tokenInput.addEventListener('input', () => {
|
||||
input.value = tokenInput.value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////
|
||||
// Optional script for token input autoformatting //
|
||||
////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const tokenInputs = document.querySelectorAll<HTMLInputElement>('input[data-enable-token-autoformat]')
|
||||
|
||||
tokenInputs.forEach((tokenInput) => {
|
||||
tokenInput.addEventListener('keydown', (e) => {
|
||||
const cursor = tokenInput.selectionStart
|
||||
if (tokenInput.selectionEnd !== cursor) return
|
||||
if (e.key === 'Delete') {
|
||||
if (cursor !== null && tokenInput.value[cursor] === '-') {
|
||||
tokenInput.selectionStart = cursor + 1
|
||||
}
|
||||
} else if (e.key === 'Backspace') {
|
||||
if (cursor !== null && cursor > 0 && tokenInput.value[cursor - 1] === '-') {
|
||||
tokenInput.selectionEnd = cursor - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tokenInput.addEventListener('input', () => {
|
||||
const value = tokenInput.value
|
||||
const cursor = tokenInput.selectionStart || 0
|
||||
|
||||
// Count dashes before cursor to adjust position
|
||||
const dashesBeforeCursor = (value.substring(0, cursor).match(/-/g) || []).length
|
||||
|
||||
// Remove all non-alphanumeric characters
|
||||
let cleaned = value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
|
||||
cleaned = cleaned.substring(0, 20) // Limit to 20 chars (24 with dashes)
|
||||
|
||||
// Format with dashes
|
||||
let formatted = ''
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
if (i > 0 && i % 4 === 0) {
|
||||
formatted += '-'
|
||||
}
|
||||
formatted += cleaned[i]
|
||||
}
|
||||
|
||||
// Only update if value changed
|
||||
if (formatted === value) return
|
||||
|
||||
// Calculate new cursor position
|
||||
let newCursor = cursor
|
||||
const dashesBeforeNew = (formatted.substring(0, cursor).match(/-/g) || []).length
|
||||
newCursor += dashesBeforeNew - dashesBeforeCursor
|
||||
|
||||
// Update input
|
||||
tokenInput.value = formatted
|
||||
tokenInput.setSelectionRange(newCursor, newCursor)
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// Optional script for making the password visible. //
|
||||
// Otherwise the password manager will not detect it as a passowrd. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-input-type-text-hack]')
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
input.type = 'text'
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
const ratings = [1, 2, 3, 4, 5] as const
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
value?: number | null
|
||||
required?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { value, required, id, ...wrapperProps } = Astro.props
|
||||
const actualValue = value !== undefined && value !== null ? Math.round(value) : null
|
||||
const inputId = id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} required={required} {...wrapperProps}>
|
||||
<div
|
||||
class="group/fieldset has-focus-visible:ring-day-200 has-focus-visible:ring-offset-night-700 relative flex items-center has-focus-visible:rounded-full has-focus-visible:ring-2 has-focus-visible:ring-offset-2 [&>*:has(~_*:hover)]:[&>[data-star]]:opacity-100!"
|
||||
>
|
||||
<label
|
||||
aria-label="Clear"
|
||||
class="has-focus-visible:before:bg-day-200 hover:before:bg-day-200 relative order-last block size-6 p-0.5 text-zinc-500 not-has-checked:cursor-pointer before:absolute before:inset-0.5 before:-z-1 before:rounded-full hover:text-black has-checked:before:hidden has-focus-visible:text-black has-focus-visible:before:block!"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={wrapperProps.name}
|
||||
value=""
|
||||
checked={actualValue === null}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
name="ri:close-line"
|
||||
class="size-full group-hover/fieldset:block group-has-focus-visible/fieldset:block peer-checked:hidden! peer-focus-visible:block! pointer-fine:hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{
|
||||
ratings.toSorted().map((rating) => (
|
||||
<label class="relative cursor-pointer [&:has(~_*:hover),&:hover]:[&>[data-star]]:opacity-100!">
|
||||
<input
|
||||
type="radio"
|
||||
name={wrapperProps.name}
|
||||
value={rating}
|
||||
checked={actualValue === rating}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<Icon name="ri:star-line" class="size-6 p-0.5 text-zinc-500" />
|
||||
<Icon
|
||||
name="ri:star-fill"
|
||||
class="absolute top-0 left-0 size-6 p-0.5 text-yellow-400 not-peer-checked:opacity-0 group-hover/fieldset:opacity-0"
|
||||
data-star
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</InputWrapper>
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
class: className,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
||||
</div>
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { omit } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
inputProps?: Omit<HTMLAttributes<'input'>, 'name'>
|
||||
inputIcon?: string
|
||||
inputIconClass?: string
|
||||
}
|
||||
|
||||
const { inputProps, inputIcon, inputIconClass, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = inputProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} required={inputProps?.required} {...wrapperProps}>
|
||||
{
|
||||
inputIcon ? (
|
||||
<div class="relative">
|
||||
<input
|
||||
transition:persist
|
||||
{...omit(inputProps, ['class', 'id', 'name'])}
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
!!inputIcon && 'pl-10',
|
||||
inputProps?.class,
|
||||
hasError && baseInputClassNames.error,
|
||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
name={wrapperProps.name}
|
||||
/>
|
||||
<Icon
|
||||
name={inputIcon}
|
||||
class={cn(
|
||||
'text-day-300 pointer-events-none absolute top-1/2 left-5.5 size-5 -translate-1/2',
|
||||
inputIconClass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
transition:persist
|
||||
{...omit(inputProps, ['class', 'id', 'name'])}
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
!!inputIcon && 'pl-10',
|
||||
inputProps?.class,
|
||||
hasError && baseInputClassNames.error,
|
||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
name={wrapperProps.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</InputWrapper>
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
import { baseInputClassNames } from '../lib/formInputs'
|
||||
|
||||
import InputWrapper from './InputWrapper.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> & {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
autofocus?: boolean
|
||||
rows?: number
|
||||
maxlength?: number
|
||||
}
|
||||
|
||||
const { value, placeholder, maxlength, disabled, autofocus, rows = 3, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<textarea
|
||||
transition:persist
|
||||
id={inputId}
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.textarea,
|
||||
hasError && baseInputClassNames.error,
|
||||
disabled && baseInputClassNames.disabled
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
required={wrapperProps.required}
|
||||
disabled={disabled}
|
||||
name={wrapperProps.name}
|
||||
autofocus={autofocus}
|
||||
maxlength={maxlength}
|
||||
rows={rows}>{value}</textarea
|
||||
>
|
||||
</InputWrapper>
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { MarkdownString } from '../lib/markdown'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
children: AstroChildren
|
||||
label: string
|
||||
name: string
|
||||
description?: MarkdownString
|
||||
descriptionLabel?: string
|
||||
required?: HTMLAttributes<'input'>['required']
|
||||
error?: string[] | string
|
||||
icon?: string
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
description,
|
||||
descriptionLabel,
|
||||
required,
|
||||
error,
|
||||
icon,
|
||||
class: className,
|
||||
inputId,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||
<label for={inputId}>{label}</label>{required && '*'}
|
||||
</legend>
|
||||
{
|
||||
!!descriptionLabel && (
|
||||
<span class="text-day-400 flex-1 basis-24 text-xs text-pretty">{descriptionLabel}</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
{
|
||||
hasError &&
|
||||
(typeof error === 'string' ? (
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
) : (
|
||||
<ul class="text-sm text-red-500">
|
||||
{error.map((e) => (
|
||||
<li>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
!!description && (
|
||||
<div class="prose prose-sm prose-invert text-day-400 text-xs text-pretty">
|
||||
<Markdown content={description} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import { karmaUnlocks } from '../constants/karmaUnlocks'
|
||||
|
||||
const karmaUnlocksSorted = orderBy(karmaUnlocks, [
|
||||
({ karma }) => (karma >= 0 ? 1 : 2),
|
||||
({ karma }) => Math.abs(karma),
|
||||
'id',
|
||||
])
|
||||
---
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Karma</th>
|
||||
<th>Unlock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
karmaUnlocksSorted.map((unlock) => (
|
||||
<tr>
|
||||
<td>{unlock.karma.toLocaleString()}</td>
|
||||
<td>{unlock.name}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'svg'>, 'viewBox' | 'xmlns'> & {
|
||||
variant?: 'mini-full' | 'mini' | 'normal' | 'small'
|
||||
}
|
||||
|
||||
const { variant = 'normal', ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
{
|
||||
variant === 'normal' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 204 28"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'small' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 124 52"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM1 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4L6.8 32.5A1 1 0 0 0 6 32H1Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1H30Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12H29v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1H50Zm27 0a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V39.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V51c0 .6.5 1 1 1h2c.6 0 1-.4 1-1V33c0-.5-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L88 47.2l-6.7-14.6a1 1 0 0 0-1-.6H77Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-14a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4ZM70 48a1 1 0 0 0-1 1v2c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'mini' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 76 52"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.5.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.5.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
variant === 'mini-full' && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 76 76"
|
||||
fill="currentColor"
|
||||
aria-label="KYCnot.me"
|
||||
{...htmlProps}
|
||||
>
|
||||
<path d="M0 1v26c0 .6.5 1 1 1h74c.6 0 1-.5 1-1V1c0-.6-.5-1-1-1H1a1 1 0 0 0-1 1Zm5 3h2c.6 0 1 .5 1 1v6c0 .6.5 1 1 1h6c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v3h3c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6c0 .6-.5 1-1 1H5a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12 0h3c.6 0 1 .5 1 1v3c0 .6-.5 1-1 1h-3a1 1 0 0 1-1-1V5c0-.6.5-1 1-1Zm12.8 0h2.4c.3 0 .7.2.8.5l5 7.8 5-7.8c.2-.3.5-.5.8-.5h2.4a1 1 0 0 1 .9 1.5l-7 10.8a1 1 0 0 0-.1.6V23c0 .6-.5 1-1 1h-2a1 1 0 0 1-1-1v-6.1l-.1-.6-7-10.8A1 1 0 0 1 30 4ZM57 4h14c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H56v12h15c.6 0 1 .5 1 1v2c0 .6-.5 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9c0-.6.5-1 1-1h3V5c0-.6.5-1 1-1ZM4.5 32a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V35.6l9.2 15.9c.2.3.5.5.8.5h5c.6 0 1-.5 1-1V33c0-.6-.5-1-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3c0-.6-.5-1-1-1h-10Zm11 4v12h3c.6 0 1-.5 1-1V37c0-.6-.5-1-1-1h-3Zm0 12h-12v3c0 .6.5 1 1 1h10c.6 0 1-.5 1-1v-3Zm-12 0V36h-3a1 1 0 0 0-1 1v10c0 .6.5 1 1 1h3Zm21-16a1 1 0 0 0-1 1v6c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-3h4v15c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V36h4v3c0 .6.5 1 1 1h2c.6 0 1-.5 1-1v-6c0-.6-.5-1-1-1h-18ZM15 56a1 1 0 0 0-1 1v18c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V63.4l5.5 12c.2.4.6.6 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75c0 .6.5 1 1 1h2c.6 0 1-.5 1-1V57c0-.6-.5-1-1-1h-3.4a1 1 0 0 0-.9.6L26 71.2l-6.7-14.6a1 1 0 0 0-1-.6H15Zm32 0a1 1 0 0 0-1 1v3h15c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47Zm-1 4h-3a1 1 0 0 0-1 1v14c0 .6.5 1 1 1h18c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1H47a1 1 0 0 1-1-1v-3h7c.6 0 1-.5 1-1v-2c0-.6-.5-1-1-1h-7v-4Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,134 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { createPageUrl } from '../lib/urls'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'nav'> & {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
currentUrl?: URL | string
|
||||
sortSeed?: string
|
||||
}
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
totalPages,
|
||||
currentUrl = Astro.url,
|
||||
sortSeed,
|
||||
class: className,
|
||||
...navProps
|
||||
} = Astro.props
|
||||
|
||||
const prevPage = currentPage > 1 ? currentPage - 1 : null
|
||||
const nextPage = currentPage < totalPages ? currentPage + 1 : null
|
||||
|
||||
const getVisiblePages = () => {
|
||||
const pages: (number | '...')[] = []
|
||||
|
||||
if (totalPages <= 9) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
if (currentPage > 4) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Calculate range around current page
|
||||
let rangeStart = Math.max(2, currentPage - 2)
|
||||
let rangeEnd = Math.min(totalPages - 1, currentPage + 2)
|
||||
|
||||
// Adjust range if at the start or end
|
||||
if (currentPage <= 4) {
|
||||
rangeEnd = 6
|
||||
}
|
||||
if (currentPage >= totalPages - 3) {
|
||||
rangeStart = totalPages - 5
|
||||
}
|
||||
|
||||
// Add range numbers
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages)
|
||||
|
||||
return pages
|
||||
}
|
||||
const PrevTag = prevPage ? 'a' : 'span'
|
||||
const NextTag = nextPage ? 'a' : 'span'
|
||||
---
|
||||
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
{...navProps}
|
||||
class={cn('flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-lg sm:flex-nowrap', className)}
|
||||
>
|
||||
<PrevTag
|
||||
href={PrevTag === 'a' && prevPage
|
||||
? createPageUrl(prevPage, currentUrl, { 'sort-seed': sortSeed })
|
||||
: undefined}
|
||||
class={cn(
|
||||
'flex w-[5.5ch] items-center justify-end text-green-500 hover:text-green-400',
|
||||
!prevPage && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<Icon name="ri:arrow-left-s-line" class="size-6 shrink-0" />
|
||||
<span class="text-green-500">Prev</span>
|
||||
</PrevTag>
|
||||
|
||||
<div class="order-first flex w-full items-center justify-center gap-4 sm:order-none sm:w-auto">
|
||||
{
|
||||
getVisiblePages().map((page) => {
|
||||
if (page === '...') {
|
||||
return <span class="text-gray-400">...</span>
|
||||
}
|
||||
|
||||
const isCurrentPage = page === currentPage
|
||||
return isCurrentPage ? (
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500 text-white"
|
||||
aria-current="page"
|
||||
aria-label={`Page ${page.toLocaleString()}`}
|
||||
disabled
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={createPageUrl(page, currentUrl, { 'sort-seed': sortSeed })}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label={`Page ${page.toLocaleString()}`}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<NextTag
|
||||
href={NextTag === 'a' && nextPage
|
||||
? createPageUrl(nextPage, currentUrl, { 'sort-seed': sortSeed })
|
||||
: undefined}
|
||||
class={cn(
|
||||
'flex w-[5.5ch] items-center justify-start text-green-500 hover:text-green-400',
|
||||
!nextPage && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<span class="text-green-500">Next</span>
|
||||
<Icon name="ri:arrow-right-s-line" class="size-6 shrink-0" />
|
||||
</NextTag>
|
||||
</nav>
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
name: string
|
||||
options: {
|
||||
value: HTMLAttributes<'input'>['value']
|
||||
label: string
|
||||
}[]
|
||||
selectedValue?: string | null
|
||||
}
|
||||
|
||||
const { name, options, selectedValue, class: className, ...rest } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'bg-night-500 divide-night-700 flex divide-x-2 overflow-hidden rounded-md text-[0.6875rem]',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
class="peer hidden"
|
||||
/>
|
||||
<span class="peer-checked:bg-night-400 inline-block cursor-pointer px-1.5 py-0.5 text-white peer-checked:text-green-500">
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { interpolate } from '../lib/numbers'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { Review, WithContext } from 'schema-dts'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
|
||||
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
|
||||
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
||||
|
||||
function makeScoreInfo(score: number, total: number) {
|
||||
const formattedScore = Math.round(score).toLocaleString()
|
||||
const angle = interpolate(progress, -100, 100)
|
||||
const n = score / total
|
||||
|
||||
if (n > 1) return { text: 'Excellent', step: 5, formattedScore, angle: 100 }
|
||||
if (n >= 0.9 && n <= 1) return { text: 'Excellent', step: 5, formattedScore, angle }
|
||||
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', step: 5, formattedScore, angle }
|
||||
if (n >= 0.6 && n < 0.8) return { text: 'Good', step: 4, formattedScore, angle }
|
||||
if (n >= 0.45 && n < 0.6) return { text: 'Average', step: 3, formattedScore, angle }
|
||||
if (n >= 0.4 && n < 0.45) return { text: 'Average', step: 3, formattedScore, angle: angle + 5 }
|
||||
if (n >= 0.2 && n < 0.4) return { text: 'Bad', step: 2, formattedScore, angle: angle + 5 }
|
||||
if (n >= 0.1 && n < 0.2) return { text: 'Very Bad', step: 1, formattedScore, angle }
|
||||
if (n >= 0 && n < 0.1) return { text: 'Terrible', step: 1, formattedScore, angle }
|
||||
if (n < 0) return { text: 'Terrible', step: 1, formattedScore, angle: -100 }
|
||||
|
||||
return { text: '', step: undefined, formattedScore, angle: undefined }
|
||||
}
|
||||
|
||||
const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
||||
---
|
||||
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
<Schema
|
||||
item={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
reviewAspect: label,
|
||||
name: `${text} ${transformCase(label, 'lower')}`,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: score,
|
||||
worstRating: 0,
|
||||
bestRating: total,
|
||||
},
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
} satisfies WithContext<Review>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
>
|
||||
<div
|
||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||
'text-score-saturate-1 text-shadow-glow': step === 1,
|
||||
'text-score-saturate-2 text-shadow-glow': step === 2,
|
||||
'text-score-saturate-3 text-shadow-glow': step === 3,
|
||||
'text-score-saturate-4 text-shadow-glow': step === 4,
|
||||
'text-score-saturate-5 text-shadow-glow': step === 5,
|
||||
'mr-[0.05em] ml-[-0.025em] text-[1.75rem] leading-[calc(2/1.75)] tracking-[-0.075em]':
|
||||
formattedScore.length > 2,
|
||||
})}
|
||||
>
|
||||
<span>{formattedScore}</span>
|
||||
</div>
|
||||
|
||||
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
|
||||
<svg class="absolute inset-0 -z-1 overflow-visible" viewBox="0 0 96 96" aria-hidden="true">
|
||||
<!-- Background segments -->
|
||||
<g opacity="0.2">
|
||||
<path
|
||||
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
|
||||
class="fill-score-saturate-1"></path>
|
||||
<path
|
||||
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
|
||||
class="fill-score-saturate-2"></path>
|
||||
<path
|
||||
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
|
||||
class="fill-score-saturate-3"></path>
|
||||
<path
|
||||
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
|
||||
class="fill-score-saturate-4"></path>
|
||||
<path
|
||||
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
|
||||
fill="#7CFF00"></path>
|
||||
</g>
|
||||
|
||||
<!-- Active segments -->
|
||||
<g>
|
||||
{
|
||||
step === 1 && (
|
||||
<path
|
||||
d="M19.84 29.962C20.8221 30.3687 21.3066 31.4709 21.0222 32.4951C20.3586 34.8848 20.0039 37.4031 20.0039 40.0042C20.0039 42.6054 20.3586 45.1237 21.0223 47.5135C21.3067 48.5376 20.8221 49.6398 19.8401 50.0466L7.05214 55.3435C5.88072 55.8288 4.55702 55.1152 4.38993 53.8583C4.13532 51.9431 4.00391 49.989 4.00391 48.0042C4.00391 40.5588 5.85316 33.5454 9.11742 27.3981C9.58659 26.5145 10.656 26.1579 11.5803 26.5407L19.84 29.962Z"
|
||||
class="text-score-saturate-1 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 2 && (
|
||||
<path
|
||||
d="M33.3538 8.55389C32.9417 7.55882 31.8133 7.06445 30.8219 7.48539C23.7527 10.4869 17.6304 15.2843 13.0275 21.3051C12.2575 22.3122 12.689 23.7526 13.8603 24.2378L20.9906 27.1912C21.9721 27.5978 23.0937 27.1616 23.6168 26.237C26.1243 21.8048 29.805 18.1241 34.2373 15.6167C35.1619 15.0936 35.5981 13.972 35.1915 12.9904L33.3538 8.55389Z"
|
||||
class="text-score-saturate-2 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 3 && (
|
||||
<path
|
||||
d="M40.4948 13.0224C39.4706 13.3068 38.3684 12.8222 37.9616 11.8402L36.316 7.86723C35.8618 6.77068 36.4564 5.51825 37.6099 5.23888C40.9424 4.43183 44.423 4.00415 48.0035 4.00415C51.5842 4.00415 55.0651 4.43188 58.3977 5.23902C59.5512 5.5184 60.1458 6.77082 59.6916 7.86736L58.046 11.8403C57.6392 12.8224 56.537 13.307 55.5128 13.0225C53.123 12.3588 50.6047 12.0042 48.0035 12.0042C45.4025 12.0042 42.8844 12.3588 40.4948 13.0224Z"
|
||||
class="text-score-saturate-3 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 4 && (
|
||||
<path
|
||||
d="M75.017 27.1913C74.0355 27.5979 72.9139 27.1617 72.3908 26.2371C69.8834 21.805 66.2028 18.1244 61.7708 15.617C60.8461 15.0938 60.41 13.9723 60.8166 12.9907L62.6542 8.55414C63.0664 7.55905 64.1948 7.06469 65.1862 7.48564C72.2552 10.4871 78.3773 15.2845 82.9801 21.3051C83.75 22.3123 83.3186 23.7527 82.1473 24.2378L75.017 27.1913Z"
|
||||
class="text-score-saturate-4 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
step === 5 && (
|
||||
<path
|
||||
d="M76.1682 50.0463C75.1862 49.6395 74.7016 48.5373 74.986 47.5131C75.6496 45.1235 76.0043 42.6053 76.0043 40.0042C76.0043 37.4031 75.6496 34.8849 74.986 32.4952C74.7016 31.471 75.1861 30.3688 76.1682 29.962L84.4279 26.5407C85.3521 26.1579 86.4216 26.5145 86.8908 27.3981C90.155 33.5454 92.0043 40.5588 92.0043 48.0042C92.0043 49.9889 91.8729 51.9429 91.6183 53.8579C91.4512 55.1148 90.1275 55.8284 88.9561 55.3432L76.1682 50.0463Z"
|
||||
class="text-score-saturate-5 drop-shadow-glow fill-current"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</g>
|
||||
|
||||
<!-- Arrow -->
|
||||
<path
|
||||
d="M47.134 9.4282C47.3126 9.7376 47.6427 9.9282 48 9.9282C48.3573 9.9282 48.6874 9.7376 48.866 9.4282L52.866 2.5C53.0447 2.1906 53.0447 1.8094 52.866 1.5C52.6874 1.1906 52.3573 1 52 1L44 1C43.6427 1 43.3126 1.1906 43.134 1.5C42.9553 1.8094 42.9553 2.1906 43.134 2.5L47.134 9.4282Z"
|
||||
fill="white"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||
class="stroke-night-700"></path>
|
||||
|
||||
<!-- Info icon -->
|
||||
<!-- <path
|
||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||
fill="white"
|
||||
fill-opacity="0.67"></path> -->
|
||||
</svg>
|
||||
</div>
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'div'> & {
|
||||
score: number
|
||||
label: string
|
||||
total?: number
|
||||
itemReviewedId?: string
|
||||
}
|
||||
|
||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
|
||||
export function makeOverallScoreInfo(score: number, total = 10) {
|
||||
const classNamesByColor = {
|
||||
red: 'bg-score-1 text-black',
|
||||
orange: 'bg-score-2 text-black',
|
||||
yellow: 'bg-score-3 text-black',
|
||||
blue: 'bg-score-4 text-black',
|
||||
green: 'bg-score-5 text-black',
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
const formattedScore = Math.round(score).toLocaleString()
|
||||
const n = score / total
|
||||
|
||||
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
if (n >= 0.5 && n < 0.6) {
|
||||
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
}
|
||||
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
|
||||
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
|
||||
return { text: '', classNameBg: undefined, formattedScore }
|
||||
}
|
||||
|
||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
---
|
||||
|
||||
<div
|
||||
{...htmlProps}
|
||||
class={cn(
|
||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
>
|
||||
{
|
||||
!!itemReviewedId && (
|
||||
<Schema
|
||||
item={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
reviewAspect: label,
|
||||
name: `${text} ${transformCase(label, 'lower')}`,
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: score,
|
||||
worstRating: 0,
|
||||
bestRating: total,
|
||||
},
|
||||
author: KYCNOTME_SCHEMA_MINI,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<!-- <svg
|
||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
||||
></path>
|
||||
</svg> -->
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'2xs:mt-2 2xs:size-12 mt-0.5 mb-1 flex size-10 shrink-0 items-center justify-center rounded-md leading-none font-bold tracking-tight text-black',
|
||||
classNameBg,
|
||||
{
|
||||
'text-[1.75rem] leading-[calc(2/1.75)] tracking-tighter': formattedScore.length > 2,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span class="2xs:text-[2rem] text-[1.5rem] leading-none font-bold tracking-tight text-black">
|
||||
{formattedScore}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="2xs:mb-0.5 text-base leading-none font-bold tracking-wide uppercase">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||
</div>
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import defaultImage from '../assets/fallback-service-image.jpg'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
inlineIcons?: boolean
|
||||
withoutLink?: boolean
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
slug: true
|
||||
description: true
|
||||
overallScore: true
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
acceptedCurrencies: true
|
||||
categories: {
|
||||
select: {
|
||||
name: true
|
||||
icon: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const {
|
||||
inlineIcons = false,
|
||||
service: {
|
||||
name = 'Unnamed Service',
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
verificationStatus,
|
||||
acceptedCurrencies,
|
||||
},
|
||||
class: className,
|
||||
withoutLink = false,
|
||||
...aProps
|
||||
} = Astro.props
|
||||
|
||||
const statusIcon = {
|
||||
...verificationStatusesByValue,
|
||||
APPROVED: undefined,
|
||||
}[verificationStatus]
|
||||
|
||||
const Element = withoutLink ? 'div' : 'a'
|
||||
|
||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
---
|
||||
|
||||
<Element
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<Image
|
||||
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
imageUrl || (defaultImage as unknown as string)}
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||
{name}{
|
||||
statusIcon && (
|
||||
<Tooltip text={statusIcon.label} position="right" class="-my-2 shrink-0">
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={statusIcon.icon}
|
||||
class={cn('inline-block size-6 shrink-0 rounded-lg p-1', statusIcon.classNames.icon)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||
<span>{category.name}</span>
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start">
|
||||
<Tooltip
|
||||
class={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||
overallScoreInfo.classNameBg
|
||||
)}
|
||||
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
|
||||
>
|
||||
{overallScoreInfo.formattedScore}
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||
KYC {kycLevel.toLocaleString()}
|
||||
</span>
|
||||
|
||||
<div class="-m-1 ml-auto flex">
|
||||
{
|
||||
currencies.map((currency) => {
|
||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||
|
||||
return (
|
||||
<Tooltip text={currency.name}>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={currency.icon}
|
||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Element>
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'a'> & {
|
||||
text: string
|
||||
searchParamName: string
|
||||
searchParamValue?: string
|
||||
icon?: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props
|
||||
|
||||
const makeUrlWithoutFilter = (filter: string, value?: string) => {
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.delete(filter, value)
|
||||
return url.toString()
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
href={makeUrlWithoutFilter(searchParamName, searchParamValue)}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'bg-night-800 hover:bg-night-900 border-night-400 flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm text-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />}
|
||||
{text}
|
||||
<Icon name="ri:close-large-line" class="text-day-400 size-4" />
|
||||
</a>
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
import { z } from 'astro/zod'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { networksBySlug } from '../constants/networks'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<HTMLAttributes<'a'>, 'href' | 'rel' | 'target'> & {
|
||||
url: string
|
||||
referral: string | null
|
||||
enableMinWidth?: boolean
|
||||
}
|
||||
|
||||
const { url: baseUrl, referral, class: className, enableMinWidth = false, ...htmlProps } = Astro.props
|
||||
|
||||
function makeLink(url: string, referral: string | null) {
|
||||
const hostname = new URL(url).hostname
|
||||
const urlWithReferral = url + (referral ?? '')
|
||||
|
||||
const onionMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,10})(\.onion)$/.exec(hostname)
|
||||
if (onionMatch) {
|
||||
return {
|
||||
type: 'onion' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: onionMatch.length
|
||||
? [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: onionMatch[1] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: '...',
|
||||
},
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: onionMatch[2] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: onionMatch[3] ?? '',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname,
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.onion.icon,
|
||||
}
|
||||
}
|
||||
|
||||
const i2pMatch = /^(?:https?:\/\/)?(.{0,10}).*?(.{0,8})((?:\.b32)?\.i2p)$/.exec(hostname)
|
||||
if (i2pMatch) {
|
||||
return {
|
||||
type: 'i2p' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: i2pMatch.length
|
||||
? [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: i2pMatch[1] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: '...',
|
||||
},
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: i2pMatch[2] ?? '',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant' as const,
|
||||
text: i2pMatch[3] ?? '',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname,
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.i2p.icon,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: [
|
||||
{
|
||||
style: 'normal' as const,
|
||||
text: hostname.replace(/^www\./, ''),
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.clearnet.icon,
|
||||
}
|
||||
}
|
||||
|
||||
const link = makeLink(baseUrl, referral)
|
||||
|
||||
if (!z.string().url().safeParse(link.url).success) {
|
||||
console.error(`Invalid service URL with referral: ${link.url}`)
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={cn(
|
||||
'2xs:text-sm 2xs:h-8 2xs:gap-2 inline-flex h-6 items-center gap-1 rounded-full bg-white text-xs whitespace-nowrap text-black',
|
||||
className
|
||||
)}
|
||||
{...htmlProps}
|
||||
>
|
||||
<Icon name={link.icon} class="2xs:ml-2 2xs:size-5 2xs:-mr-0.5 -mr-0.25 ml-1 size-4" />
|
||||
<span class={cn('font-title font-bold', { 'min-w-[29ch]': enableMinWidth })}>
|
||||
{
|
||||
link.textBits.map((textBit) => (
|
||||
<span class={cn(textBit.style === 'irrelevant' && 'text-zinc-500')}>{textBit.text}</span>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
<Icon
|
||||
name="ri:arrow-right-line"
|
||||
class="2xs:size-6 mr-1 size-4 rounded-full bg-orange-500 p-0.5 text-white"
|
||||
/>
|
||||
</a>
|
||||
@@ -1,497 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { cn } from '../lib/cn'
|
||||
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
export type Props = HTMLAttributes<'form'> & {
|
||||
filters: ServicesFiltersObject
|
||||
hasDefaultFilters: boolean
|
||||
options: ServicesFiltersOptions
|
||||
searchResultsId: string
|
||||
showFiltersId: string
|
||||
}
|
||||
|
||||
const {
|
||||
filters,
|
||||
hasDefaultFilters,
|
||||
options,
|
||||
searchResultsId,
|
||||
showFiltersId,
|
||||
class: className,
|
||||
...formProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<form
|
||||
method="GET"
|
||||
hx-get={Astro.url.pathname}
|
||||
hx-trigger={`input delay:500ms from:input[type='text'], keyup[key=='Enter'], change from:input:not([data-show-more-input], #${showFiltersId}), change from:select`}
|
||||
hx-target={`#${searchResultsId}`}
|
||||
hx-select={`#${searchResultsId}`}
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
data-services-filters-form
|
||||
data-default-verification-filter={options.verification
|
||||
.filter((verification) => verification.default)
|
||||
.map((verification) => verification.slug)}
|
||||
{...formProps}
|
||||
class={cn('', className)}
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-title text-xl text-green-500">FILTERS</h2>
|
||||
<a
|
||||
href={Astro.url.pathname}
|
||||
class={cn('text-sm text-green-500 hover:text-green-400', hasDefaultFilters && 'hidden')}
|
||||
id="clear-filters-button">Clear all</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Sort Selector -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="sort">Sort By</label>
|
||||
</legend>
|
||||
<select
|
||||
name="sort"
|
||||
id="sort"
|
||||
class="border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
|
||||
>
|
||||
{
|
||||
options.sort.map((option) => (
|
||||
<option value={option.value} selected={filters.sort === option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Ties randomly sorted
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
id="q"
|
||||
value={filters?.q}
|
||||
placeholder="Search..."
|
||||
class="placeholder-day-500 border-night-600 bg-night-900 w-full rounded-md border p-2 text-white focus:border-green-500 focus:outline-hidden"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Type</legend>
|
||||
<input type="checkbox" id="show-more-categories" class="peer hidden" hx-preserve data-show-more-input />
|
||||
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="categories"
|
||||
value={category.slug}
|
||||
checked={category.checked}
|
||||
/>
|
||||
<span class="peer-checked:font-bold">
|
||||
{category.name}
|
||||
<span class="text-day-500 font-normal">{category._count.services}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{
|
||||
options.categories.filter((category) => category.showAlways).length < options.categories.length && (
|
||||
<>
|
||||
<label
|
||||
for="show-more-categories"
|
||||
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
|
||||
>
|
||||
+ Show more
|
||||
</label>
|
||||
<label
|
||||
for="show-more-categories"
|
||||
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
|
||||
>
|
||||
- Show less
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Verification</legend>
|
||||
<div>
|
||||
{
|
||||
options.verification.map((verification) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="verification"
|
||||
value={verification.slug}
|
||||
checked={filters.verification.includes(verification.value)}
|
||||
/>
|
||||
<Icon name={verification.icon} class={cn('size-4', verification.classNames.icon)} />
|
||||
<span class="peer-checked:font-bold">{verification.labelShort}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Accepted currencies Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<legend class="font-title leading-none text-green-500">Currencies</legend>
|
||||
<PillsRadioGroup
|
||||
name="currency-mode"
|
||||
options={options.modeOptions}
|
||||
selectedValue={filters['currency-mode']}
|
||||
class="-my-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
options.currencies.map((currency) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="currencies"
|
||||
value={currency.slug}
|
||||
checked={filters.currencies?.some((id) => id === currency.id)}
|
||||
/>
|
||||
<Icon name={currency.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">{currency.name}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Network Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">Networks</legend>
|
||||
<div>
|
||||
{
|
||||
options.network.map((network) => (
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
name="networks"
|
||||
value={network.slug}
|
||||
checked={filters.networks?.some((slug) => slug === network.slug)}
|
||||
/>
|
||||
<Icon name={network.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">{network.name}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- KYC Level Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="max-kyc">KYC Level (max)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="4"
|
||||
name="max-kyc"
|
||||
id="max-kyc"
|
||||
value={filters['max-kyc'] ?? 4}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-1 text-xs">
|
||||
{
|
||||
kycLevels.map((level) => (
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
{level.value}
|
||||
<Icon name={level.icon} class="ms-1 size-3 shrink-0" />
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- User Score Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="user-rating">User Rating (min)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={4}
|
||||
name="user-rating"
|
||||
id="user-rating"
|
||||
value={filters['user-rating']}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-day-400 mt-1 flex justify-between px-2 text-xs">
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">-</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
1<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
2<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
3<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
<span class="flex w-0 items-center justify-center text-center whitespace-nowrap">
|
||||
4<Icon name="ri:star-line" class="size-3 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Attributes Filter -->
|
||||
<fieldset class="mb-6 min-w-0 space-y-2">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<legend class="font-title leading-none text-green-500">Attributes</legend>
|
||||
<PillsRadioGroup
|
||||
name="attribute-mode"
|
||||
options={options.modeOptions}
|
||||
selectedValue={filters['attribute-mode']}
|
||||
class="-my-2"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
options.attributesByCategory.map(({ category, attributes }) => (
|
||||
<fieldset class="min-w-0">
|
||||
<legend class="font-title mb-0.5 text-xs tracking-wide text-white">{category}</legend>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`show-more-attributes-${category}`}
|
||||
class="peer hidden"
|
||||
hx-preserve
|
||||
data-show-more-input
|
||||
/>
|
||||
<ul class="not-peer-checked:[&>li:not([data-show-always])]:hidden">
|
||||
{attributes.map((attribute) => {
|
||||
const inputName = `attr-${attribute.id}` as const
|
||||
const yesId = `attr-${attribute.id}=yes` as const
|
||||
const noId = `attr-${attribute.id}=no` as const
|
||||
const emptyId = `attr-${attribute.id}=empty` as const
|
||||
const isPositive = attribute.type === 'GOOD' || attribute.type === 'INFO'
|
||||
|
||||
return (
|
||||
<li data-show-always={attribute.showAlways ? '' : undefined} class="cursor-pointer">
|
||||
<fieldset class="flex max-w-full min-w-0 cursor-pointer items-center text-sm text-white">
|
||||
<legend class="sr-only">
|
||||
{attribute.title} ({attribute._count?.services})
|
||||
</legend>
|
||||
<input
|
||||
type="radio"
|
||||
class="peer/empty hidden"
|
||||
id={emptyId}
|
||||
name={inputName}
|
||||
value=""
|
||||
checked={!attribute.value}
|
||||
aria-label="Ignore"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputName}
|
||||
value="yes"
|
||||
id={yesId}
|
||||
class="peer/yes hidden"
|
||||
checked={attribute.value === 'yes'}
|
||||
aria-label="Include"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputName}
|
||||
value="no"
|
||||
id={noId}
|
||||
class="peer/no hidden"
|
||||
checked={attribute.value === 'no'}
|
||||
aria-label="Exclude"
|
||||
/>
|
||||
|
||||
<label
|
||||
for={yesId}
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-zinc-950 peer-checked/yes:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:check-line" class="size-3" />
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-l-sm bg-green-600 peer-checked/yes:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:check-line" class="size-3" />
|
||||
</label>
|
||||
|
||||
<span class="block h-4 w-px border-y-2 border-zinc-950 bg-zinc-800" aria-hidden="true" />
|
||||
|
||||
<label
|
||||
for={noId}
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-zinc-950 peer-checked/no:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:close-line" class="size-3" />
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="hidden size-4 shrink-0 cursor-pointer items-center justify-center rounded-r-sm bg-red-600 peer-checked/no:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="ri:close-line" class="size-3" />
|
||||
</label>
|
||||
|
||||
<label
|
||||
for={isPositive ? yesId : noId}
|
||||
class="ml-2 flex min-w-0 cursor-pointer items-center font-normal peer-checked/no:hidden peer-checked/yes:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={attribute.icon}
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-80', attribute.iconClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
</label>
|
||||
<label
|
||||
for={emptyId}
|
||||
class="ml-2 hidden min-w-0 cursor-pointer items-center font-bold peer-checked/no:flex peer-checked/yes:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon
|
||||
name={attribute.icon}
|
||||
class={cn('mr-2 size-3 shrink-0 opacity-100', attribute.iconClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{attribute.title}
|
||||
</span>
|
||||
<span class="text-day-500 ml-2 font-normal">{attribute._count?.services}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{attributes.filter((attribute) => attribute.showAlways).length < attributes.length && (
|
||||
<>
|
||||
<label
|
||||
for={`show-more-attributes-${category}`}
|
||||
class="mt-2 block cursor-pointer text-sm text-green-500 peer-checked:hidden"
|
||||
>
|
||||
+ Show more
|
||||
</label>
|
||||
<label
|
||||
for={`show-more-attributes-${category}`}
|
||||
class="mt-2 hidden cursor-pointer text-sm text-green-500 peer-checked:block"
|
||||
>
|
||||
- Show less
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
))
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<!-- Score Filter -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="min-score">Score (min)</label>
|
||||
</legend>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
name="min-score"
|
||||
id="min-score"
|
||||
value={filters['min-score']}
|
||||
class="w-full accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mx-1.5 mt-2 flex justify-between px-1">
|
||||
{
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((score) => {
|
||||
const info = makeOverallScoreInfo(score)
|
||||
return (
|
||||
<Tooltip
|
||||
text={info.text}
|
||||
position="bottom"
|
||||
class={cn(
|
||||
'flex h-4 w-full max-w-4 min-w-0 cursor-default items-center justify-center rounded-xs text-xs font-bold tracking-tighter',
|
||||
info.classNameBg
|
||||
)}
|
||||
>
|
||||
{score.toLocaleString()}
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="sort-seed" value={filters['sort-seed']} />
|
||||
|
||||
<div
|
||||
class="sm:js:hidden bg-night-700 sticky inset-x-0 bottom-0 mt-4 block rounded-t-md pb-4 shadow-[0_0_16px_16px_var(--color-night-700)]"
|
||||
>
|
||||
<Button type="submit" label="Apply" size="lg" class="w-full" color="success" shadow />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const forms = document.querySelectorAll<HTMLFormElement>('form[data-services-filters-form]')
|
||||
|
||||
forms.forEach((form) => {
|
||||
form.addEventListener('input', () => {
|
||||
form.querySelectorAll<HTMLAnchorElement>('a#clear-filters-button').forEach((button) => {
|
||||
button.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const verificationInputs = form.querySelectorAll<HTMLInputElement>('input[name="verification"]')
|
||||
const noVerificationChecked = Array.from(verificationInputs).every((input) => !input.checked)
|
||||
if (noVerificationChecked) {
|
||||
verificationInputs.forEach((input) => {
|
||||
if (form.dataset.defaultVerificationFilter?.includes(input.value)) {
|
||||
input.checked = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { pluralize } from '../lib/pluralize'
|
||||
import { createPageUrl } from '../lib/urls'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import ServiceCard from './ServiceCard.astro'
|
||||
|
||||
import type { ServicesFiltersObject } from '../pages/index.astro'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hasDefaultFilters?: boolean
|
||||
services: ComponentProps<typeof ServiceCard>['service'][] | undefined
|
||||
currentPage?: number
|
||||
total: number
|
||||
pageSize: number
|
||||
sortSeed?: string
|
||||
filters: ServicesFiltersObject
|
||||
hadToIncludeCommunityContributed: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
services,
|
||||
hasDefaultFilters = false,
|
||||
currentPage = 1,
|
||||
total,
|
||||
pageSize,
|
||||
sortSeed,
|
||||
class: className,
|
||||
filters,
|
||||
hadToIncludeCommunityContributed,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||
const hasCommunityContributed =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
filters.verification.includes('COMMUNITY_CONTRIBUTED') || hadToIncludeCommunityContributed
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('flex-1', className)}>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<span class="text-day-500 text-sm">
|
||||
{total.toLocaleString()}
|
||||
{pluralize('result', total)}
|
||||
|
||||
<span
|
||||
id="search-indicator"
|
||||
class="htmx-request:opacity-100 text-white opacity-0 transition-opacity duration-500"
|
||||
>
|
||||
<Icon name="ri:loader-4-line" class="inline-block size-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
</span>
|
||||
<Button as="a" href="/service-suggestion/new" label="Add service" icon="ri:add-line" />
|
||||
</div>
|
||||
|
||||
{
|
||||
hasScams && hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
|
||||
<Icon name="ri:alert-fill" class="-mr-1 inline-block size-4 text-red-500" />
|
||||
<Icon name="ri:question-line" class="mr-2 inline-block size-4 text-yellow-500" />
|
||||
Showing SCAM and unverified community-contributed services.
|
||||
{hadToIncludeCommunityContributed && 'Because there were no other results.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
hasScams && !hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-red-500/30 bg-red-950 p-4 text-sm text-red-500">
|
||||
<Icon name="ri:alert-fill" class="mr-2 inline-block size-4 text-red-500" />
|
||||
Showing SCAM services!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!hasScams && hasCommunityContributed && (
|
||||
<div class="font-title mb-6 rounded-lg border border-yellow-500/30 bg-yellow-950 p-4 text-sm text-yellow-500">
|
||||
<Icon name="ri:question-line" class="mr-2 inline-block size-4" />
|
||||
|
||||
{hadToIncludeCommunityContributed
|
||||
? 'Showing unverified community-contributed services, because there were no other results. Some might be scams.'
|
||||
: 'Showing unverified community-contributed services, some might be scams.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!services || services.length === 0 ? (
|
||||
<div class="sticky top-20 flex flex-col items-center justify-center rounded-lg border border-green-500/30 bg-black/40 p-12 text-center">
|
||||
<Icon name="ri:emotion-sad-line" class="mb-4 size-16 text-green-500/50" />
|
||||
<h3 class="font-title mb-3 text-xl text-green-500">No services found</h3>
|
||||
<p class="text-day-400">Try adjusting your filters to find more services</p>
|
||||
<a
|
||||
href={Astro.url.pathname}
|
||||
class={cn(
|
||||
'bg-night-800 font-title mt-4 rounded-md px-4 py-2 text-sm tracking-wider text-white uppercase',
|
||||
hasDefaultFilters && 'hidden'
|
||||
)}
|
||||
>
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*80),1fr))]">
|
||||
{services.map((service, i) => (
|
||||
<ServiceCard
|
||||
inlineIcons
|
||||
service={service}
|
||||
data-hx-search-results-card
|
||||
{...(i === services.length - 1 && currentPage < totalPages
|
||||
? {
|
||||
'hx-get': createPageUrl(currentPage + 1, Astro.url, { 'sort-seed': sortSeed }),
|
||||
'hx-trigger': 'revealed',
|
||||
'hx-swap': 'afterend',
|
||||
'hx-select': '[data-hx-search-results-card]',
|
||||
'hx-indicator': '#infinite-scroll-indicator',
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="no-js:hidden mt-8 flex justify-center" id="infinite-scroll-indicator">
|
||||
<div class="htmx-request:opacity-100 flex items-center gap-2 opacity-0 transition-opacity duration-500">
|
||||
<Icon name="ri:loader-4-line" class="size-8 animate-spin text-green-500" />
|
||||
Loading more services...
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
type Props = {
|
||||
active: boolean
|
||||
sortOrder: 'asc' | 'desc' | null | undefined
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { active, sortOrder, class: className }: Props = Astro.props
|
||||
---
|
||||
|
||||
{
|
||||
active && sortOrder ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<Icon name="ri:arrow-down-s-line" class={cn('inline-block size-4', className)} />
|
||||
) : (
|
||||
<Icon name="ri:arrow-up-s-line" class={cn('inline-block size-4', className)} />
|
||||
)
|
||||
) : (
|
||||
<Icon name="ri:expand-up-down-line" class={cn('inline-block size-4 text-current/50', className)} />
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
document.body.classList.add('js')
|
||||
|
||||
document.addEventListener('astro:before-swap', (event) => {
|
||||
event.newDocument.body.classList.add('js')
|
||||
})
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
import { omit } from 'lodash-es'
|
||||
|
||||
import { formatDateShort, type FormatDateShortOptions } from '../lib/timeAgo'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = FormatDateShortOptions &
|
||||
Omit<HTMLAttributes<'time'>, keyof FormatDateShortOptions | 'datetime'> & {
|
||||
date: Date
|
||||
}
|
||||
|
||||
const { date, ...props } = Astro.props
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()} {...omit(props, 'prefix')}>{formatDateShort(date, props)}</time>
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren, AstroComponent, PolymorphicComponent } from '../lib/astro'
|
||||
import type { HTMLTag } from 'astro/types'
|
||||
|
||||
type Props<Component extends AstroComponent | HTMLTag = 'span'> = PolymorphicComponent<Component> & {
|
||||
children: AstroChildren
|
||||
text: string
|
||||
classNames?: {
|
||||
tooltip?: string
|
||||
}
|
||||
color?: 'black' | 'white' | 'zinc-700'
|
||||
position?: 'bottom' | 'left' | 'right' | 'top'
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as: Component = 'span',
|
||||
text,
|
||||
classNames,
|
||||
class: className,
|
||||
color = 'zinc-700',
|
||||
position = 'top',
|
||||
enabled = true,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<Component {...htmlProps} class={cn('group/tooltip relative overflow-visible', className)}>
|
||||
<slot />
|
||||
{
|
||||
enabled && (
|
||||
<span
|
||||
tabindex="-1"
|
||||
class={cn(
|
||||
'pointer-events-none hidden select-none group-hover/tooltip:flex',
|
||||
'ease-out-cubic scale-75 opacity-0 transition-all transition-discrete duration-100 group-hover/tooltip:scale-100 group-hover/tooltip:opacity-100 starting:group-hover/tooltip:scale-75 starting:group-hover/tooltip:opacity-0',
|
||||
'z-1000 w-max max-w-sm rounded-lg px-3 py-2 font-sans text-sm font-normal tracking-normal text-pretty whitespace-pre-wrap',
|
||||
// Position classes
|
||||
{
|
||||
'absolute -top-2 left-1/2 origin-bottom -translate-x-1/2 translate-y-[calc(-100%+0.5rem)] text-center group-hover/tooltip:-translate-y-full starting:group-hover/tooltip:translate-y-[calc(-100%+0.25rem)]':
|
||||
position === 'top',
|
||||
'absolute -bottom-2 left-1/2 origin-top -translate-x-1/2 translate-y-[calc(100%-0.5rem)] text-center group-hover/tooltip:translate-y-full starting:group-hover/tooltip:translate-y-[calc(100%-0.25rem)]':
|
||||
position === 'bottom',
|
||||
'absolute top-1/2 -left-2 origin-right translate-x-[calc(-100%+0.5rem)] -translate-y-1/2 text-left group-hover/tooltip:-translate-x-full starting:group-hover/tooltip:translate-x-[calc(-100%+0.25rem)]':
|
||||
position === 'left',
|
||||
'absolute top-1/2 -right-2 origin-left translate-x-[calc(100%-0.5rem)] -translate-y-1/2 text-left group-hover/tooltip:translate-x-full starting:group-hover/tooltip:translate-x-[calc(100%-0.25rem)]':
|
||||
position === 'right',
|
||||
},
|
||||
// Arrow position classes
|
||||
{
|
||||
'after:absolute after:top-[100%] after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:content-[""]':
|
||||
position === 'top',
|
||||
'after:absolute after:bottom-[100%] after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-t-transparent after:content-[""]':
|
||||
position === 'bottom',
|
||||
'after:absolute after:top-1/2 after:left-[100%] after:-translate-y-1/2 after:border-8 after:border-y-transparent after:border-r-transparent after:content-[""]':
|
||||
position === 'left',
|
||||
'after:absolute after:top-1/2 after:right-[100%] after:-translate-y-1/2 after:border-8 after:border-y-transparent after:border-l-transparent after:content-[""]':
|
||||
position === 'right',
|
||||
},
|
||||
// Background and text color classes
|
||||
{
|
||||
'bg-zinc-700 text-white': color === 'zinc-700',
|
||||
'bg-white text-black': color === 'white',
|
||||
'bg-black text-white': color === 'black',
|
||||
},
|
||||
// Arrow color classes
|
||||
{
|
||||
'after:border-t-zinc-700': position === 'top' && color === 'zinc-700',
|
||||
'after:border-t-white': position === 'top' && color === 'white',
|
||||
'after:border-t-black': position === 'top' && color === 'black',
|
||||
|
||||
'after:border-b-zinc-700': position === 'bottom' && color === 'zinc-700',
|
||||
'after:border-b-white': position === 'bottom' && color === 'white',
|
||||
'after:border-b-black': position === 'bottom' && color === 'black',
|
||||
|
||||
'after:border-l-zinc-700': position === 'left' && color === 'zinc-700',
|
||||
'after:border-l-white': position === 'left' && color === 'white',
|
||||
'after:border-l-black': position === 'left' && color === 'black',
|
||||
|
||||
'after:border-r-zinc-700': position === 'right' && color === 'zinc-700',
|
||||
'after:border-r-white': position === 'right' && color === 'white',
|
||||
'after:border-r-black': position === 'right' && color === 'black',
|
||||
},
|
||||
classNames?.tooltip
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</Component>
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const RECENTLY_ADDED_DAYS = 7
|
||||
|
||||
type Props = {
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
verificationProofMd: true
|
||||
verificationSummary: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const { service } = Astro.props
|
||||
|
||||
const listedDate = service.listedAt ?? service.createdAt
|
||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
||||
---
|
||||
|
||||
{
|
||||
service.verificationStatus === 'VERIFICATION_FAILED' ? (
|
||||
<div class="mb-4 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
<p class="flex items-center gap-2">
|
||||
<Icon
|
||||
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
|
||||
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
|
||||
/>
|
||||
<span class="font-bold">This service is a SCAM!</span>
|
||||
{!!service.verificationProofMd && (
|
||||
<a href="#verification" class="cursor-pointer text-red-100 underline">
|
||||
Proof
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{!!service.verificationSummary && (
|
||||
<div class="mt-2 whitespace-pre-wrap">{service.verificationSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>Community-contributed. Information not reviewed.</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
</div>
|
||||
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>Basic checks passed, but not fully verified.</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
Reference in New Issue
Block a user