Release 202506101742
This commit is contained in:
41
web/package-lock.json
generated
41
web/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
@@ -320,6 +321,16 @@
|
||||
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/rss": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.12.tgz",
|
||||
"integrity": "sha512-O5yyxHuDVb6DQ6VLOrbUVFSm+NpObulPxjs6XT9q3tC+RoKbN4HXMZLpv0LvXd1qdAjzVgJ1NFD+zKHJNDXikw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"kleur": "^4.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/sitemap": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.4.1.tgz",
|
||||
@@ -9789,6 +9800,24 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
||||
@@ -16567,6 +16596,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.2.2",
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;
|
||||
@@ -135,6 +135,7 @@ enum NotificationType {
|
||||
COMMUNITY_NOTE_ADDED
|
||||
/// Comment that is not a reply. May include a rating.
|
||||
ROOT_COMMENT_CREATED
|
||||
SUGGESTION_CREATED
|
||||
SUGGESTION_MESSAGE
|
||||
SUGGESTION_STATUS_CHANGE
|
||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
@@ -497,6 +498,7 @@ model User {
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
feedId String @unique @default(cuid(2))
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
totalKarma Int @default(0)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
privacy_score INT := 50; -- Start from middle value (50)
|
||||
privacy_score INT := 0;
|
||||
kyc_factor INT;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
@@ -78,7 +78,7 @@ BEGIN
|
||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||
|
||||
-- Calculate final privacy score (base 100)
|
||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
privacy_score := 50 + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||
@@ -91,9 +91,11 @@ $$ LANGUAGE plpgsql;
|
||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
trust_score INT := 50; -- Start from middle value (50)
|
||||
trust_score INT := 0;
|
||||
verification_factor INT;
|
||||
attributes_score INT := 0;
|
||||
recently_listed_factor INT := 0;
|
||||
tos_penalty_factor INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
@@ -124,7 +126,7 @@ BEGIN
|
||||
AND "verificationStatus" = 'APPROVED'
|
||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||
) THEN
|
||||
trust_score := trust_score - 10;
|
||||
recently_listed_factor := -10;
|
||||
-- Update the isRecentlyListed flag to true
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = TRUE
|
||||
@@ -144,12 +146,12 @@ BEGIN
|
||||
AND "tosReviewAt" IS NOT NULL
|
||||
AND "tosReview" IS NULL
|
||||
) THEN
|
||||
trust_score := trust_score - 3;
|
||||
tos_penalty_factor := -3;
|
||||
END IF;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := trust_score + verification_factor + attributes_score;
|
||||
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
|
||||
|
||||
@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
|
||||
-- Notify all admins when a new suggestion is created
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
|
||||
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
|
||||
FROM "User" u
|
||||
WHERE u."admin" = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = u."id"
|
||||
AND n."type" = 'SUGGESTION_CREATED'
|
||||
AND n."aboutServiceSuggestionId" = NEW."id"
|
||||
);
|
||||
|
||||
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||
-- Notify suggestion author (if not the sender)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||
@@ -55,6 +68,13 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for new suggestions
|
||||
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
|
||||
CREATE TRIGGER service_suggestion_created_notifications_trigger
|
||||
AFTER INSERT ON "ServiceSuggestion"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||
|
||||
-- Trigger for new messages
|
||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||
|
||||
@@ -36,6 +36,10 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -58,6 +62,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (Array.isArray(value)) {
|
||||
serializedValue = value.map((item) => String(item)).join(', ')
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
@@ -144,17 +150,7 @@ export const serviceSuggestionActions = {
|
||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
.refine(
|
||||
async (slug) => {
|
||||
const exists = await prisma.service.findUnique({
|
||||
select: { id: true },
|
||||
where: { slug },
|
||||
})
|
||||
return !exists
|
||||
},
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
}),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
@@ -189,8 +185,16 @@ export const serviceSuggestionActions = {
|
||||
location: 'serviceSuggestion.createService',
|
||||
})
|
||||
|
||||
const serviceWithSameSlug = await prisma.service.findUnique({
|
||||
select: { id: true, name: true, slug: true, description: true },
|
||||
where: { slug: input.slug },
|
||||
})
|
||||
|
||||
if (!input.skipDuplicateCheck) {
|
||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
||||
const possibleDuplicates = [
|
||||
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
||||
...(await findPossibleDuplicates(input)),
|
||||
]
|
||||
|
||||
if (possibleDuplicates.length > 0) {
|
||||
return {
|
||||
@@ -208,6 +212,13 @@ export const serviceSuggestionActions = {
|
||||
service: undefined,
|
||||
} as const
|
||||
}
|
||||
} else {
|
||||
if (serviceWithSameSlug) {
|
||||
throw new ActionError({
|
||||
message: 'Slug already in use, try a different one',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
|
||||
@@ -13,6 +13,7 @@ import HtmxScript from './HtmxScript.astro'
|
||||
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||
import { makeOgImageUrl } from './OgImage'
|
||||
import ServerEventsScript from './ServerEventsScript.astro'
|
||||
import ServiceWorkerScript from './ServiceWorkerScript.astro'
|
||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
@@ -137,10 +138,12 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
))
|
||||
}
|
||||
|
||||
<!-- Server events -->
|
||||
<ServerEventsScript />
|
||||
|
||||
<!-- Push Notifications -->
|
||||
|
||||
<script src="/src/pwa.ts"></script>
|
||||
<NotificationEventsScript />
|
||||
{
|
||||
Astro.locals.user && (
|
||||
<>
|
||||
<ServerEventsScript />
|
||||
<ServiceWorkerScript />
|
||||
<NotificationEventsScript />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ function addBadgeIfUnread(href: string) {
|
||||
}
|
||||
|
||||
<script>
|
||||
document.addEventListener('sse-new-notification', () => {
|
||||
document.addEventListener('sse:new-notification', () => {
|
||||
const links = document.querySelectorAll('link[rel="icon"]')
|
||||
links.forEach((link) => {
|
||||
const href = link.getAttribute('href')
|
||||
|
||||
@@ -33,6 +33,12 @@ const links = [
|
||||
icon: 'ri:plug-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/feeds',
|
||||
label: 'RSS',
|
||||
icon: 'ri:rss-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
@@ -49,7 +55,10 @@ const links = [
|
||||
const { class: className, ...htmlProps } = Astro.props
|
||||
---
|
||||
|
||||
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
|
||||
<footer
|
||||
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
|
||||
{...htmlProps}
|
||||
>
|
||||
{
|
||||
links.map(
|
||||
({ href, label, icon, external }) =>
|
||||
@@ -58,9 +67,9 @@ const { class: className, ...htmlProps } = Astro.props
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||
class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||
>
|
||||
<Icon name={icon} class="h-4 w-4" />
|
||||
<Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ const count =
|
||||
}
|
||||
|
||||
<script>
|
||||
document.addEventListener('sse-new-notification', () => {
|
||||
document.addEventListener('sse:new-notification', () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
|
||||
const currentCount = Number(link.getAttribute('data-current-count') || 0)
|
||||
const newCount = currentCount + 1
|
||||
|
||||
61
web/src/components/InputCheckbox.astro
Normal file
61
web/src/components/InputCheckbox.astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type InputWrapper from './InputWrapper.astro'
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
} & (
|
||||
| {
|
||||
label: string
|
||||
children?: undefined
|
||||
}
|
||||
| {
|
||||
label?: undefined
|
||||
children: AstroChildren
|
||||
}
|
||||
)
|
||||
|
||||
const { disabled, name, required, error, id, label } = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
{}
|
||||
|
||||
<div>
|
||||
<label
|
||||
class={cn(
|
||||
'inline-flex cursor-pointer items-center gap-2',
|
||||
hasError && 'text-red-300',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
id={id}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
class={cn(disabled && 'opacity-50')}
|
||||
/>
|
||||
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||
</label>
|
||||
|
||||
{
|
||||
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>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
class={cn(
|
||||
baseInputClassNames.input,
|
||||
baseInputClassNames.textarea,
|
||||
!!inputProps?.rows && 'h-auto',
|
||||
inputProps?.class,
|
||||
hasError && baseInputClassNames.error,
|
||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||
import { makeNotificationOptions } from '../lib/notificationOptions'
|
||||
|
||||
document.addEventListener('sse-new-notification', (event) => {
|
||||
document.addEventListener('sse:new-notification', (event) => {
|
||||
if (isBrowserNotificationsEnabled()) {
|
||||
const payload = event.detail
|
||||
const notification = showBrowserNotification(
|
||||
|
||||
@@ -31,7 +31,7 @@ if (!Astro.locals.user) return
|
||||
const data = JSON.parse(event.data as string)
|
||||
|
||||
if (isServerEventsEvent(data)) {
|
||||
const eventType = `sse-${data.type}` as const
|
||||
const eventType = `sse:${data.type}` as const
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
|
||||
)
|
||||
|
||||
54
web/src/components/ServiceWorkerScript.astro
Normal file
54
web/src/components/ServiceWorkerScript.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Window {
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
|
||||
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||
|
||||
let hasPendingUpdate = false
|
||||
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onRegisteredSW: (_swScriptUrl, registration) => {
|
||||
if (registration) window.__SW_REGISTRATION__ = registration
|
||||
|
||||
document.addEventListener('astro:after-swap', checkAndApplyPendingUpdate, { passive: true })
|
||||
window.addEventListener('popstate', checkAndApplyPendingUpdate, { passive: true })
|
||||
},
|
||||
onNeedRefresh: () => {
|
||||
if (shouldSkipAutoReload()) {
|
||||
void updateSW(false)
|
||||
hasPendingUpdate = true
|
||||
return
|
||||
}
|
||||
|
||||
void updateSW(true)
|
||||
},
|
||||
onRegisterError: (error) => {
|
||||
console.error('Service Worker registration error', error)
|
||||
},
|
||||
})
|
||||
|
||||
function shouldSkipAutoReload() {
|
||||
const currentPath = window.location.pathname
|
||||
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
|
||||
|
||||
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||
}
|
||||
|
||||
function checkAndApplyPendingUpdate() {
|
||||
if (hasPendingUpdate && !shouldSkipAutoReload()) {
|
||||
hasPendingUpdate = false
|
||||
void updateSW(true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -40,6 +40,11 @@ export const {
|
||||
label: 'New comment/rating',
|
||||
icon: 'ri:chat-4-line',
|
||||
},
|
||||
{
|
||||
id: 'SUGGESTION_CREATED',
|
||||
label: 'New suggestion',
|
||||
icon: 'ri:lightbulb-line',
|
||||
},
|
||||
{
|
||||
id: 'SUGGESTION_MESSAGE',
|
||||
label: 'New message in suggestion',
|
||||
|
||||
@@ -8,6 +8,7 @@ type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
labelAlt: string
|
||||
icon: string
|
||||
order: number
|
||||
default: boolean
|
||||
@@ -33,12 +34,14 @@ export const {
|
||||
order: Infinity,
|
||||
default: false,
|
||||
color: 'zinc',
|
||||
labelAlt: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'CREATE_SERVICE',
|
||||
slug: 'create',
|
||||
label: 'Create',
|
||||
labelAlt: 'service',
|
||||
icon: 'ri:add-line',
|
||||
order: 1,
|
||||
default: true,
|
||||
@@ -48,6 +51,7 @@ export const {
|
||||
value: 'EDIT_SERVICE',
|
||||
slug: 'edit',
|
||||
label: 'Edit',
|
||||
labelAlt: 'edit',
|
||||
icon: 'ri:pencil-line',
|
||||
order: 2,
|
||||
default: false,
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = ComponentProps<typeof BaseHead> & {
|
||||
children: AstroChildren
|
||||
errors?: string[]
|
||||
success?: string[]
|
||||
className?: {
|
||||
classNames?: {
|
||||
body?: string
|
||||
main?: string
|
||||
footer?: string
|
||||
@@ -31,14 +31,16 @@ type Props = ComponentProps<typeof BaseHead> & {
|
||||
| 'max-w-screen-sm'
|
||||
| 'max-w-screen-xl'
|
||||
| 'max-w-screen-xs'
|
||||
isErrorPage?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
success = [],
|
||||
className,
|
||||
classNames,
|
||||
widthClassName = 'max-w-screen-2xl',
|
||||
showSplashText,
|
||||
isErrorPage,
|
||||
...baseHeadProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -77,7 +79,10 @@ const announcement = await Astro.locals.banners.try(
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<BaseHead {...baseHeadProps} />
|
||||
</head>
|
||||
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}>
|
||||
<body
|
||||
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
|
||||
data-is-error-page={isErrorPage}
|
||||
>
|
||||
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||
<Header
|
||||
classNames={{
|
||||
@@ -116,7 +121,7 @@ const announcement = await Astro.locals.banners.try(
|
||||
<main
|
||||
class={cn(
|
||||
'container mx-auto mt-4 mb-12 grow px-4',
|
||||
className?.main,
|
||||
classNames?.main,
|
||||
(widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
|
||||
widthClassName
|
||||
)}
|
||||
@@ -124,6 +129,6 @@ const announcement = await Astro.locals.banners.try(
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer class={className?.footer} />
|
||||
<Footer class={classNames?.footer} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,20 +9,30 @@ import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||
size?: 'md' | 'xs'
|
||||
}
|
||||
|
||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
const { layoutHeader, size = 'xs', ...baseLayoutProps } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
className={{
|
||||
...baseLayoutProps.className,
|
||||
main: cn('xs:items-center-safe flex grow flex-col justify-center-safe', baseLayoutProps.className?.main),
|
||||
classNames={{
|
||||
main: cn(
|
||||
'flex grow flex-col justify-center-safe',
|
||||
{
|
||||
'xs:items-center-safe': size === 'xs',
|
||||
'md:items-center-safe': size === 'md',
|
||||
},
|
||||
baseLayoutProps.classNames?.main
|
||||
),
|
||||
}}
|
||||
{...baseLayoutProps}
|
||||
>
|
||||
<div
|
||||
class="bg-night-800 border-night-500 xs:block xs:max-w-screen-xs contents w-full rounded-xl border p-8"
|
||||
class={cn('bg-night-800 border-night-500 contents w-full rounded-xl border p-8', {
|
||||
'xs:block xs:max-w-screen-xs': size === 'xs',
|
||||
'md:block md:max-w-screen-md': size === 'md',
|
||||
})}
|
||||
>
|
||||
<div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg">
|
||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||
@@ -31,14 +41,24 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
<h1
|
||||
class={cn(
|
||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
||||
!layoutHeader.subtitle && {
|
||||
'xs:mb-8 mb-6': size === 'xs',
|
||||
'mb-6 md:mb-8': size === 'md',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{layoutHeader.title}
|
||||
</h1>
|
||||
{
|
||||
!!layoutHeader.subtitle && (
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
<p
|
||||
class={cn('text-day-500 mt-1 mb-6 text-center', {
|
||||
'xs:mb-8': size === 'xs',
|
||||
'md:mb-8': size === 'md',
|
||||
})}
|
||||
>
|
||||
{layoutHeader.subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,233 @@ import { formatDateShort } from './timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
type NonDbAttribute = Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
title: true
|
||||
type: true
|
||||
category: true
|
||||
description: true
|
||||
privacyPoints: true
|
||||
trustPoints: true
|
||||
}
|
||||
}> & {
|
||||
slug: string
|
||||
links: {
|
||||
url: string
|
||||
label: string
|
||||
icon: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export const nonDbAttributes: (NonDbAttribute & {
|
||||
customize: (
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
onionUrls: true
|
||||
i2pUrls: true
|
||||
acceptedCurrencies: true
|
||||
}
|
||||
}>
|
||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||
show: boolean
|
||||
}
|
||||
})[] = [
|
||||
{
|
||||
slug: 'verification-verified',
|
||||
title: 'Verified',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-approved',
|
||||
title: 'Approved',
|
||||
type: 'INFO',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'APPROVED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-community-contributed',
|
||||
title: 'Community contributed',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=community',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'verification-scam',
|
||||
title: 'Is SCAM',
|
||||
type: 'BAD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=scam',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'archived',
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'recently-listed',
|
||||
title: 'Recently listed',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.isRecentlyListed,
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'cannot-analyse-tos',
|
||||
title: "Can't analyse ToS",
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -3,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-onion-urls',
|
||||
title: 'Has Onion URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?onion=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.onionUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'has-i2p-urls',
|
||||
title: 'Has I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?i2p=true',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.i2pUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'monero-accepted',
|
||||
title: 'Accepts Monero',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description:
|
||||
'This service accepts Monero, a privacy-focused cryptocurrency that provides enhanced anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?currency=monero',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.acceptedCurrencies.includes('MONERO'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
export function sortAttributes<
|
||||
T extends Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
@@ -43,135 +270,19 @@ export function makeNonDbAttributes(
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
onionUrls: true
|
||||
i2pUrls: true
|
||||
acceptedCurrencies: true
|
||||
}
|
||||
}>,
|
||||
{ filter = false }: { filter?: boolean } = {}
|
||||
) {
|
||||
const nonDbAttributes: (Prisma.AttributeGetPayload<{
|
||||
select: {
|
||||
title: true
|
||||
type: true
|
||||
category: true
|
||||
description: true
|
||||
privacyPoints: true
|
||||
trustPoints: true
|
||||
}
|
||||
}> & {
|
||||
show: boolean
|
||||
links: {
|
||||
url: string
|
||||
label: string
|
||||
icon: string
|
||||
}[]
|
||||
})[] = [
|
||||
{
|
||||
title: 'Verified',
|
||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||
type: 'GOOD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
show: service.verificationStatus === 'APPROVED',
|
||||
type: 'INFO',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community contributed',
|
||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=community',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Is SCAM',
|
||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||
type: 'BAD',
|
||||
category: 'TRUST',
|
||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||
links: [
|
||||
{
|
||||
url: '/?verification=scam',
|
||||
label: 'With this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
{
|
||||
url: '/?verification=verified&verification=approved',
|
||||
label: 'Without this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: 'Recently listed',
|
||||
show: service.isRecentlyListed,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
privacyPoints: 0,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: "Can't analyse ToS",
|
||||
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description:
|
||||
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||
privacyPoints: 0,
|
||||
trustPoints: -3,
|
||||
links: [],
|
||||
},
|
||||
]
|
||||
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
|
||||
...attribute,
|
||||
...customize(service),
|
||||
}))
|
||||
|
||||
if (filter) return nonDbAttributes.filter(({ show }) => show)
|
||||
if (filter) return attributes.filter(({ show }) => show)
|
||||
|
||||
return nonDbAttributes
|
||||
return attributes
|
||||
}
|
||||
|
||||
323
web/src/lib/feeds.ts
Normal file
323
web/src/lib/feeds.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
type SafeResult<T> =
|
||||
| {
|
||||
success: false
|
||||
error: { message: string; responseInit: ResponseInit }
|
||||
data?: undefined
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
error?: undefined
|
||||
data: T
|
||||
}
|
||||
|
||||
export const takeCounts = {
|
||||
serviceComments: 100,
|
||||
serviceEvents: 100,
|
||||
allEvents: 100,
|
||||
userNotifications: 50,
|
||||
} as const satisfies Record<string, number>
|
||||
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
export async function getService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
}>
|
||||
> {
|
||||
if (!slug || typeof slug !== 'string') {
|
||||
return { success: false, error: { message: 'Invalid slug', responseInit: { status: 400 } } }
|
||||
}
|
||||
|
||||
const service =
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
slug,
|
||||
},
|
||||
select: serviceSelect,
|
||||
})) ??
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
previousSlugs: { has: slug },
|
||||
},
|
||||
select: serviceSelect,
|
||||
}))
|
||||
|
||||
if (!service) {
|
||||
return { success: false, error: { message: 'Service not found', responseInit: { status: 404 } } }
|
||||
}
|
||||
|
||||
return { success: true, data: { service } }
|
||||
}
|
||||
|
||||
const serviceCommentSelect = {
|
||||
id: true,
|
||||
content: true,
|
||||
rating: true,
|
||||
ratingActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
verified: true,
|
||||
admin: true,
|
||||
moderator: true,
|
||||
spammer: true,
|
||||
serviceAffiliations: {
|
||||
select: {
|
||||
role: true,
|
||||
service: { select: { name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.CommentSelect
|
||||
|
||||
export async function getCommentsForService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
comments: Prisma.CommentGetPayload<{ select: typeof serviceCommentSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const result = await getService(slug)
|
||||
if (!result.success) return result
|
||||
const { service } = result.data
|
||||
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||
suspicious: false,
|
||||
parentId: null, // Only root comments for the main feed
|
||||
},
|
||||
select: serviceCommentSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.serviceComments,
|
||||
})
|
||||
|
||||
return { success: true, data: { service, comments } }
|
||||
}
|
||||
|
||||
const eventSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.EventSelect
|
||||
|
||||
const serviceEventSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
} as const satisfies Prisma.EventSelect
|
||||
|
||||
export async function getEventsForService(slug: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||
events: Prisma.EventGetPayload<{ select: typeof serviceEventSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const result = await getService(slug)
|
||||
if (!result.success) return result
|
||||
const { service } = result.data
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
visible: true,
|
||||
},
|
||||
select: serviceEventSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.serviceEvents,
|
||||
})
|
||||
|
||||
return { success: true, data: { service, events } }
|
||||
}
|
||||
|
||||
export async function getEvents(): Promise<
|
||||
SafeResult<{
|
||||
events: Prisma.EventGetPayload<{ select: typeof eventSelect }>[]
|
||||
}>
|
||||
> {
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
visible: true,
|
||||
service: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
select: eventSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.allEvents,
|
||||
})
|
||||
|
||||
return { success: true, data: { events } }
|
||||
}
|
||||
|
||||
const userSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
} as const satisfies Prisma.UserSelect
|
||||
|
||||
const notificationSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
status: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies Prisma.NotificationSelect
|
||||
|
||||
export async function getUserNotifications(feedId: string | undefined): Promise<
|
||||
SafeResult<{
|
||||
user: Prisma.UserGetPayload<{ select: typeof userSelect }>
|
||||
notifications: Prisma.NotificationGetPayload<{ select: typeof notificationSelect }>[]
|
||||
}>
|
||||
> {
|
||||
if (!feedId || typeof feedId !== 'string') {
|
||||
return { success: false, error: { message: 'Invalid feed ID', responseInit: { status: 400 } } }
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { feedId, spammer: false },
|
||||
select: userSelect,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: { message: 'User not found', responseInit: { status: 404 } } }
|
||||
}
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: notificationSelect,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: takeCounts.userNotifications,
|
||||
})
|
||||
|
||||
return { success: true, data: { user, notifications } }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { commentStatusChangesById } from '../constants/commentStatusChange'
|
||||
import { eventTypesById } from '../constants/eventTypes'
|
||||
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
||||
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
||||
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
|
||||
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
@@ -48,6 +49,7 @@ export function makeNotificationTitle(
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true
|
||||
type: true
|
||||
service: {
|
||||
select: {
|
||||
name: true
|
||||
@@ -131,6 +133,12 @@ export function makeNotificationTitle(
|
||||
? `New unmoderated comment on ${service}`
|
||||
: `New comment on ${service}`
|
||||
}
|
||||
case 'SUGGESTION_CREATED': {
|
||||
if (!notification.aboutServiceSuggestion) return 'New service suggestion'
|
||||
const typeInfo = getServiceSuggestionTypeInfo(notification.aboutServiceSuggestion.type)
|
||||
const service = notification.aboutServiceSuggestion.service.name
|
||||
return `New ${typeInfo.labelAlt} suggestion for ${service}`
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
||||
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
||||
@@ -219,6 +227,7 @@ export function makeNotificationContent(
|
||||
if (!notification.aboutKarmaTransaction) return null
|
||||
return notification.aboutKarmaTransaction.description
|
||||
}
|
||||
case 'SUGGESTION_CREATED':
|
||||
case 'SUGGESTION_STATUS_CHANGE':
|
||||
case 'ACCOUNT_STATUS_CHANGE':
|
||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||
@@ -323,6 +332,17 @@ export function makeNotificationActions(
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_CREATED': {
|
||||
if (!notification.aboutServiceSuggestionId) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return []
|
||||
return [
|
||||
|
||||
@@ -53,6 +53,7 @@ export async function sendNotification(
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
|
||||
@@ -45,7 +45,7 @@ export type ServerEventsEvent = {
|
||||
}[keyof ServerEventsData]
|
||||
|
||||
export type SSEEventMap = {
|
||||
[K in keyof ServerEventsData as `sse-${K}`]: CustomEvent<ServerEventsData[K]>
|
||||
[K in keyof ServerEventsData as `sse:${K}`]: CustomEvent<ServerEventsData[K]>
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
|
||||
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
cleanUrl,
|
||||
z.string().refine((value) => /^(https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*$/i.test(value), {
|
||||
z.string().refine((value) => /^(https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^((https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
/^((https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
value
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Window {
|
||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onRegisteredSW: (_swScriptUrl, registration) => {
|
||||
if (registration) window.__SW_REGISTRATION__ = registration
|
||||
},
|
||||
onNeedRefresh: () => {
|
||||
void updateSW(true)
|
||||
},
|
||||
onRegisterError: (error) => {
|
||||
console.error('Service Worker registration error', error)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user