Release 202506101742
This commit is contained in:
@@ -7,11 +7,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
<BaseLayout
|
||||
pageTitle="404: Page Not Found"
|
||||
description="The page doesn't exist, double check the URL."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]',
|
||||
footer: 'bg-black',
|
||||
}}
|
||||
widthClassName="max-w-none"
|
||||
isErrorPage
|
||||
>
|
||||
<h1
|
||||
data-text="404"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { z } from 'astro/zod'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import { SUPPORT_EMAIL } from '../constants/project'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
@@ -21,9 +22,10 @@ const {
|
||||
<BaseLayout
|
||||
pageTitle="500: Server Error"
|
||||
description="Sorry, something crashed on the server."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
|
||||
}}
|
||||
isErrorPage
|
||||
>
|
||||
<Icon
|
||||
name="ri:bug-line"
|
||||
@@ -93,6 +95,20 @@ const {
|
||||
/>
|
||||
Contact support
|
||||
</a>
|
||||
{
|
||||
Astro.locals.user?.admin && !!LOGS_UI_URL && (
|
||||
<a
|
||||
href={LOGS_UI_URL}
|
||||
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
|
||||
>
|
||||
<Icon
|
||||
name="ri:menu-search-line"
|
||||
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
|
||||
/>
|
||||
View logs <span class="text-xs text-gray-400">(admin only)</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
||||
<BaseLayout
|
||||
pageTitle="403: Access Denied"
|
||||
description="You don't have permission to access this page."
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
|
||||
body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50',
|
||||
}}
|
||||
|
||||
@@ -169,7 +169,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
icon: 'ri:user-3-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'space-y-6',
|
||||
}}
|
||||
breadcrumbs={[
|
||||
|
||||
@@ -372,7 +372,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-thin max-w-full overflow-x-auto">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="min-w-[750px]">
|
||||
<table class="w-full divide-y divide-zinc-700">
|
||||
<thead class="bg-zinc-900/30">
|
||||
@@ -721,51 +721,3 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base CSS text size utility */
|
||||
.text-2xs {
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
line-height: 1rem; /* 16px */
|
||||
}
|
||||
|
||||
/* Scrollbar styling for better mobile experience */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(30, 41, 59, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
|
||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,9 +16,6 @@ const releaseDate =
|
||||
title: 'Releases',
|
||||
subtitle: 'Current release',
|
||||
}}
|
||||
className={{
|
||||
main: 'flex flex-col items-center justify-center text-center',
|
||||
}}
|
||||
>
|
||||
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
|
||||
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
||||
@@ -36,7 +33,7 @@ const releaseDate =
|
||||
</time>
|
||||
{
|
||||
!!releaseDate && (
|
||||
<p class="text-day-500 mt-2">
|
||||
<p class="text-day-500 mt-2 text-center">
|
||||
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
/>
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
description="One per line. AI review uses the first working URL only."
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||
|
||||
@@ -120,7 +120,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
<BaseLayout
|
||||
pageTitle={`${user.displayName ?? user.name} - User`}
|
||||
widthClassName="max-w-screen-lg"
|
||||
className={{ main: 'space-y-24' }}
|
||||
classNames={{ main: 'space-y-24' }}
|
||||
>
|
||||
<div class="mt-12">
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { sortAttributes } from '../lib/attributes'
|
||||
import { nonDbAttributes, sortAttributes } from '../lib/attributes'
|
||||
import { cn } from '../lib/cn'
|
||||
import { formatNumber } from '../lib/numbers'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
@@ -59,9 +59,14 @@ const attributes = await Astro.locals.banners.try(
|
||||
)
|
||||
|
||||
const sortBy = filters['sort-by']
|
||||
const mergedAttributes = [
|
||||
...nonDbAttributes.map((attribute) => ({ ...attribute, services: [], id: attribute.slug })),
|
||||
...attributes,
|
||||
]
|
||||
|
||||
const sortedAttributes = sortBy
|
||||
? orderBy(
|
||||
sortAttributes(attributes),
|
||||
sortAttributes(mergedAttributes),
|
||||
sortBy === 'type'
|
||||
? (attribute) => getAttributeTypeInfo(attribute.type).order
|
||||
: sortBy === 'category'
|
||||
@@ -73,7 +78,7 @@ const sortedAttributes = sortBy
|
||||
: 'trustPoints',
|
||||
filters['sort-order']
|
||||
)
|
||||
: sortAttributes(attributes)
|
||||
: sortAttributes(mergedAttributes)
|
||||
|
||||
const attributesWithInfo = sortedAttributes.map((attribute) => ({
|
||||
...attribute,
|
||||
@@ -292,7 +297,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
|
||||
<label
|
||||
for={`show-services-${attribute.id}`}
|
||||
class="col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180"
|
||||
class={cn(
|
||||
'col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180',
|
||||
attribute.services.length === 0 && 'cursor-default'
|
||||
)}
|
||||
aria-label={`Show services for ${attribute.title}`}
|
||||
>
|
||||
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
|
||||
@@ -339,7 +347,9 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
|
||||
{attribute.services.length > 0 && (
|
||||
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -47,11 +47,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
events: {
|
||||
some: {
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -162,7 +157,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
|
||||
pageTitle="Events"
|
||||
description="Discover important events, updates, and news about KYC-free services in chronological order."
|
||||
widthClassName="max-w-screen-lg"
|
||||
className={{ main: 'sm:flex sm:items-start sm:gap-6' }}
|
||||
classNames={{ main: 'sm:flex sm:items-start sm:gap-6' }}
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Events',
|
||||
|
||||
41
web/src/pages/feeds/events.xml.ts
Normal file
41
web/src/pages/feeds/events.xml.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import { SITE_URL } from 'astro:env/client'
|
||||
|
||||
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||
import { getEvents } from '../../lib/feeds'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
try {
|
||||
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||
|
||||
const result = await getEvents()
|
||||
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||
const { events } = result.data
|
||||
|
||||
return await rss({
|
||||
title: 'KYCnot.me - Service Events',
|
||||
description: 'Latest events and updates from privacy-focused services tracked on KYCnot.me',
|
||||
site: origin,
|
||||
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||
items: events.map((event) => {
|
||||
const eventTypeInfo = getEventTypeInfo(event.type)
|
||||
const isOngoing = !event.endedAt || event.endedAt > new Date()
|
||||
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
|
||||
|
||||
return {
|
||||
title: `${event.service.name}: ${event.title}`,
|
||||
pubDate: event.createdAt,
|
||||
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
|
||||
link: `/service/${event.service.slug}/#event-${String(event.id)}`,
|
||||
categories: [eventTypeInfo.label, event.service.name, statusText],
|
||||
}
|
||||
}),
|
||||
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating events RSS feed:', error)
|
||||
return new Response('Error generating RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
87
web/src/pages/feeds/index.astro
Normal file
87
web/src/pages/feeds/index.astro
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||
import { takeCounts } from '../../lib/feeds'
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
const feeds = [
|
||||
{
|
||||
title: user ? 'Your notifications' : 'User notifications',
|
||||
description: `Last ${takeCounts.userNotifications.toLocaleString()} of your notifications`,
|
||||
rss: user ? `/feeds/user/${user.feedId}/notifications.xml` : '/feeds/user/[feedId]/notifications.xml',
|
||||
icon: 'ri:notification-line',
|
||||
replacementDescription: user
|
||||
? null
|
||||
: "Replace [feedId] with the user feed ID, found in the user's notifications page",
|
||||
},
|
||||
{
|
||||
title: 'Service comments',
|
||||
description: `Last ${takeCounts.serviceComments.toLocaleString()} comments (and reviews) from a specific service`,
|
||||
rss: '/feeds/service/[slug]/comments.xml',
|
||||
icon: 'ri:chat-3-line',
|
||||
replacementDescription: 'Replace [slug] with the actual service slug',
|
||||
},
|
||||
{
|
||||
title: 'Service events',
|
||||
description: `Last ${takeCounts.serviceEvents.toLocaleString()} events from a specific service`,
|
||||
rss: '/feeds/service/[slug]/events.xml',
|
||||
icon: 'ri:calendar-event-line',
|
||||
replacementDescription: 'Replace [slug] with the actual service slug',
|
||||
},
|
||||
{
|
||||
title: 'All events',
|
||||
description: `Last ${takeCounts.allEvents.toLocaleString()} events from all listed services`,
|
||||
rss: '/feeds/events.xml',
|
||||
icon: 'ri:calendar-2-line',
|
||||
replacementDescription: null,
|
||||
},
|
||||
] as const satisfies {
|
||||
title: string
|
||||
description: string
|
||||
rss: `/feeds/${string}`
|
||||
icon: string
|
||||
replacementDescription: string | null
|
||||
}[]
|
||||
---
|
||||
|
||||
<MiniLayout
|
||||
pageTitle="RSS Feeds"
|
||||
description="Subscribe to RSS feeds to stay updated with the latest comments and events on KYCnot.me"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'RSS Feeds',
|
||||
description: 'Subscribe to stay updated',
|
||||
icon: 'ri:rss-line',
|
||||
}}
|
||||
layoutHeader={{
|
||||
icon: 'ri:rss-line',
|
||||
title: 'RSS Feeds',
|
||||
subtitle: 'Copy the feed URL to your RSS reader',
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
<div class="space-y-8">
|
||||
{
|
||||
feeds.map((feed) => (
|
||||
<div>
|
||||
<div class="flex flex-row items-center justify-center gap-2">
|
||||
<Icon name={feed.icon} class="inline-block size-6 shrink-0 text-white" />
|
||||
<h2 class="text-left text-lg leading-tight font-bold text-white">{feed.title}</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-day-300 mt-1 text-center text-sm text-balance">{feed.description}</p>
|
||||
|
||||
<div
|
||||
class="border-night-500 bg-night-600 relative mt-2 rounded-lg border px-4 py-2 font-mono break-all text-white"
|
||||
set:text={`${Astro.url.origin}${feed.rss}`}
|
||||
/>
|
||||
{!!feed.replacementDescription && (
|
||||
<p class="text-day-500 mt-1 text-center text-xs italic">{feed.replacementDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</MiniLayout>
|
||||
61
web/src/pages/feeds/service/[slug]/comments.xml.ts
Normal file
61
web/src/pages/feeds/service/[slug]/comments.xml.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import { SITE_URL } from 'astro:env/client'
|
||||
|
||||
import { getServiceUserRoleInfo } from '../../../../constants/serviceUserRoles'
|
||||
import { makeCommentUrl } from '../../../../lib/commentsWithReplies'
|
||||
import { getCommentsForService } from '../../../../lib/feeds'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
try {
|
||||
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||
|
||||
const result = await getCommentsForService(context.params.slug)
|
||||
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||
const { service, comments } = result.data
|
||||
|
||||
return await rss({
|
||||
title: `${service.name} - Comments & Reviews | KYCnot.me`,
|
||||
description: `Latest comments and reviews about ${service.name} from KYCnot.me users`,
|
||||
site: origin,
|
||||
xmlns: { dc: 'http://purl.org/dc/elements/1.1/', atom: 'http://www.w3.org/2005/Atom' },
|
||||
items: comments.map((comment) => {
|
||||
const authorName = comment.author.displayName ?? comment.author.name
|
||||
const isRating = comment.ratingActive && comment.rating
|
||||
const title = isRating
|
||||
? `${authorName} rated ${service.name} (${String(comment.rating)}/5 stars)`
|
||||
: `${authorName} commented on ${service.name}`
|
||||
|
||||
const badges = [
|
||||
comment.author.verified ? '✅' : null,
|
||||
comment.author.spammer ? '(Spammer)' : null,
|
||||
comment.author.admin ? '(Admin)' : null,
|
||||
comment.author.moderator && !comment.author.admin ? '(Moderator)' : null,
|
||||
...comment.author.serviceAffiliations.map(
|
||||
(affiliation) =>
|
||||
` (${getServiceUserRoleInfo(affiliation.role).label} at ${affiliation.service.name})`
|
||||
),
|
||||
].filter((badge) => badge !== null)
|
||||
|
||||
return {
|
||||
title,
|
||||
pubDate: comment.createdAt,
|
||||
description: comment.content,
|
||||
link: makeCommentUrl({
|
||||
origin,
|
||||
serviceSlug: service.slug,
|
||||
commentId: comment.id,
|
||||
}),
|
||||
categories: isRating ? ['Rating'] : ['Comment'],
|
||||
guid: `${service.slug}-comment-${String(comment.id)}`,
|
||||
customData: `<dc:creator>${authorName}${badges.length > 0 ? ` ${badges.join(' ')}` : ''}</dc:creator>`,
|
||||
}
|
||||
}),
|
||||
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating service comments RSS feed:', error)
|
||||
return new Response('Error generating RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
41
web/src/pages/feeds/service/[slug]/events.xml.ts
Normal file
41
web/src/pages/feeds/service/[slug]/events.xml.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import { SITE_URL } from 'astro:env/client'
|
||||
|
||||
import { getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||
import { getEventsForService } from '../../../../lib/feeds'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
try {
|
||||
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||
|
||||
const result = await getEventsForService(context.params.slug)
|
||||
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||
const { service, events } = result.data
|
||||
|
||||
return await rss({
|
||||
title: `${service.name} - Events & Updates | KYCnot.me`,
|
||||
description: `Latest events and updates for ${service.name} tracked on KYCnot.me`,
|
||||
site: origin,
|
||||
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||
items: events.map((event) => {
|
||||
const eventTypeInfo = getEventTypeInfo(event.type)
|
||||
const isOngoing = !event.endedAt || event.endedAt > new Date()
|
||||
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
|
||||
|
||||
return {
|
||||
title: `${service.name}: ${event.title}`,
|
||||
pubDate: event.createdAt,
|
||||
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
|
||||
link: `/service/${service.slug}/#event-${String(event.id)}`,
|
||||
categories: [eventTypeInfo.label, statusText],
|
||||
}
|
||||
}),
|
||||
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating service events RSS feed:', error)
|
||||
return new Response('Error generating RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
52
web/src/pages/feeds/user/[feedId]/notifications.xml.ts
Normal file
52
web/src/pages/feeds/user/[feedId]/notifications.xml.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import { SITE_URL } from 'astro:env/client'
|
||||
|
||||
import { getNotificationTypeInfo } from '../../../../constants/notificationTypes'
|
||||
import { getUserNotifications } from '../../../../lib/feeds'
|
||||
import {
|
||||
makeNotificationActions,
|
||||
makeNotificationContent,
|
||||
makeNotificationTitle,
|
||||
} from '../../../../lib/notifications'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
try {
|
||||
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||
const feedId = context.params.feedId
|
||||
|
||||
const result = await getUserNotifications(feedId)
|
||||
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||
const { user, notifications } = result.data
|
||||
|
||||
const displayName = user.displayName ?? user.name
|
||||
|
||||
return await rss({
|
||||
title: `${displayName}'s Notifications - KYCnot.me`,
|
||||
description: `Notifications for ${displayName} on KYCnot.me - Privacy-focused service reviews and updates`,
|
||||
site: origin,
|
||||
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||
items: notifications.map((notification) => {
|
||||
const typeInfo = getNotificationTypeInfo(notification.type)
|
||||
|
||||
const title = makeNotificationTitle(notification, user)
|
||||
const description = makeNotificationContent(notification) ?? typeInfo.label
|
||||
const actions = makeNotificationActions(notification, origin)
|
||||
const link = actions[0]?.url ?? `${origin}/notifications`
|
||||
|
||||
return {
|
||||
title,
|
||||
pubDate: notification.createdAt,
|
||||
description,
|
||||
link,
|
||||
categories: [typeInfo.label],
|
||||
}
|
||||
}),
|
||||
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating user notifications RSS feed:', error)
|
||||
return new Response('Error generating RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import Button from '../components/Button.astro'
|
||||
import CopyButton from '../components/CopyButton.astro'
|
||||
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||
import Tooltip from '../components/Tooltip.astro'
|
||||
@@ -84,6 +85,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -256,7 +258,7 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
label="Reload"
|
||||
icon="ri:refresh-line"
|
||||
color="white"
|
||||
class="ml-auto"
|
||||
class="no-js:hidden ml-auto"
|
||||
onclick="window.location.reload()"
|
||||
/>
|
||||
</div>
|
||||
@@ -404,11 +406,73 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
class="relative isolate mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow-sm"
|
||||
>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-zinc-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-zinc-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="rounded-md bg-zinc-800 p-2">
|
||||
<Icon name="ri:rss-line" class="size-6 text-zinc-300" />
|
||||
</div>
|
||||
<h3 class="font-title text-xl font-bold text-zinc-200">RSS feeds available</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-1 text-sm text-zinc-400">
|
||||
Subscribe to receive your notifications in your favorite RSS reader.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
|
||||
class="flex-1 rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 select-all"
|
||||
/>
|
||||
<CopyButton
|
||||
copyText={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
|
||||
color="white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/feeds"
|
||||
class="flex items-center justify-between rounded-lg border border-zinc-700/50 bg-zinc-800/30 p-3"
|
||||
>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-zinc-300">Public RSS feeds</h4>
|
||||
<p class="text-xs text-zinc-500">
|
||||
Don't require an account to subscribe. Includes service comments and events.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
as="span"
|
||||
label="Browse all"
|
||||
icon="ri:arrow-right-line"
|
||||
variant="faded"
|
||||
color="white"
|
||||
class="pointer-events-none"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('sse-new-notification', () => {
|
||||
document.addEventListener('sse:new-notification', () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
|
||||
banner.style.display = ''
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions } from 'astro:actions'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../actions/serviceSuggestion'
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||
import InputCheckbox from '../../components/InputCheckbox.astro'
|
||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||
import InputImageFile from '../../components/InputImageFile.astro'
|
||||
@@ -36,7 +37,7 @@ const result = Astro.getActionResult(actions.serviceSuggestion.createService)
|
||||
if (result && !result.error && !result.data.hasDuplicates) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = result?.error?.code === 'VALIDATION_ERROR' ? result.error.fields : {}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
@@ -239,7 +240,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
|
||||
<InputTextArea
|
||||
label="ToS URLs"
|
||||
description="One per line"
|
||||
description="One per line. AI review uses the first working URL only."
|
||||
name="tosUrls"
|
||||
inputProps={{
|
||||
placeholder: 'example.com/tos',
|
||||
@@ -349,16 +350,10 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-lg">
|
||||
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required />
|
||||
<label for="rules-confirm">
|
||||
I understand the
|
||||
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
|
||||
</label>
|
||||
</div>
|
||||
{inputErrors.rulesConfirm && <p class="mt-1 text-sm text-red-500">{inputErrors.rulesConfirm?.[0]}</p>}
|
||||
</div>
|
||||
<InputCheckbox name="rulesConfirm" required error={inputErrors.rulesConfirm}>
|
||||
I understand the
|
||||
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
|
||||
</InputCheckbox>
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
icon: 'ri:user-3-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
className={{
|
||||
classNames={{
|
||||
main: 'space-y-6',
|
||||
}}
|
||||
htmx
|
||||
|
||||
Reference in New Issue
Block a user