Compare commits

...

15 Commits

Author SHA1 Message Date
pluja
cf5f3b3228 Release 202506131423 2025-06-13 14:23:14 +00:00
pluja
5a41816ac8 Release 202506131339 2025-06-13 13:39:12 +00:00
pluja
bf30a6cb2b Release 202506130639 2025-06-13 06:39:29 +00:00
pluja
4ca9b9a5c2 Release 202506112249 2025-06-11 22:49:54 +00:00
pluja
03abdef4f1 Release 202506112228 2025-06-11 22:28:53 +00:00
pluja
d9880fd83d Release 202506112108 2025-06-11 21:08:32 +00:00
pluja
39afcad089 Release 202506111705 2025-06-11 17:05:58 +00:00
pluja
99cb730bc0 Release 202506111039 2025-06-11 10:39:20 +00:00
pluja
d43402e162 Release 202506111007 2025-06-11 10:07:51 +00:00
pluja
9bb316b85f Release 202506102027 2025-06-10 20:27:48 +00:00
pluja
4aea68ee58 Release 202506101914 2025-06-10 19:14:10 +00:00
pluja
2f88c43236 Release 202506101800 2025-06-10 18:00:24 +00:00
pluja
ad3c561419 Release 202506101748 2025-06-10 17:48:58 +00:00
pluja
812937d2c7 Release 202506101742 2025-06-10 17:42:42 +00:00
pluja
459d7c91f7 Release 202506091901 2025-06-09 19:01:08 +00:00
86 changed files with 2210 additions and 712 deletions

View File

@@ -366,7 +366,7 @@ def run_worker_mode() -> int:
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service-score-recalc",
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)

View File

@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
return remove_service_attribute(service_id, attribute_id)
def save_tos_review(service_id: int, review: TosReviewType):
"""
Save a TOS review for a specific service.
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
"""Persist a TOS review and/or update the timestamp for a service.
Args:
service_id: The ID of the service.
review: A TypedDict containing the review data.
If *review* is ``None`` the existing review (if any) is preserved while
only the ``tosReviewAt`` column is updated. This ensures we still track
when the review task last ran even if the review generation failed or
produced no changes.
"""
try:
# Serialize the dictionary to a JSON string for the database
review_json = json.dumps(review)
with get_db_connection() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(
"""
UPDATE "Service"
SET "tosReview" = %s, "tosReviewAt" = NOW()
WHERE id = %s
""",
(review_json, service_id),
)
if review is None:
cursor.execute(
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
(service_id,),
)
else:
review_json = json.dumps(review)
cursor.execute(
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
(review_json, service_id),
)
conn.commit()
logger.info(f"Successfully saved TOS review for service {service_id}")
logger.info(
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
)
except Exception as e:
logger.error(f"Error saving TOS review for service {service_id}: {e}")

View File

@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
import hashlib
from typing import Any, Dict, Optional
import requests
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
from pyworker.tasks.base import Task
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
)
self.logger.info(f"TOS URLs: {tos_urls}")
review = self.get_tos_review(tos_urls, service.get("tosReview"))
# Always update the processed timestamp, even if review is None
save_tos_review(service_id, review)
if review is None:
self.logger.warning(
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
)
return None
# Update the KYC level based on the review, when present
if "kycLevel" in review:
new_level = review["kycLevel"]
old_level = service.get("kycLevel")
# Update DB
if update_kyc_level(service_id, new_level):
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
# Log to console
self.logger.info(msg)
# Send notification via ntfy
try:
requests.post(
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
)
except requests.RequestException as e:
self.logger.error(
f"Failed to send ntfy notification for KYC level change: {e}"
)
return review
def get_tos_review(
self, tos_urls: list[str], current_review: Optional[TosReviewType]
) -> Optional[TosReviewType]:
"""
Get TOS review from a list of URLs.
Args:
tos_urls: List of TOS URLs to check
current_review: Current review data from the database
Returns:
Dict containing:
- status: Literal["skipped", "failed", "success"]
- review: Optional[TosReviewType] - The review data if successful
"""
all_skipped = True
for tos_url in tos_urls:
api_url = f"{tos_url}"
self.logger.info(f"Fetching TOS from URL: {api_url}")
# Sleep for 1 second to avoid rate limiting
content = fetch_markdown(api_url)
if content:
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# service.get("tosReview") can be None if the DB field is NULL.
# Default to an empty dict to prevent AttributeError on .get()
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
"tosReview"
)
tos_review: Dict[str, Any] = (
tos_review_data_from_service
if tos_review_data_from_service is not None
else {}
)
stored_hash = tos_review.get("contentHash")
# Skip processing if we've seen this content before
if stored_hash == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check:
continue
elif not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
# Save the review to the database
save_tos_review(service_id, review)
# Update the KYC level based on the review
if "kycLevel" in review:
kyc_level = review["kycLevel"]
self.logger.info(
f"Updating KYC level to {kyc_level} for service {service_name}"
)
update_kyc_level(service_id, kyc_level)
# no need to check other TOS URLs
break
return review
else:
if not content:
self.logger.warning(
f"Failed to retrieve TOS content for URL: {tos_url}"
)
all_skipped = False
continue
# Hash the content to avoid repeating the same content
content_hash = hashlib.sha256(content.encode()).hexdigest()
self.logger.info(f"Content hash: {content_hash}")
# Skip processing if we've seen this content before
if current_review and current_review.get("contentHash") == content_hash:
self.logger.info(
f"Skipping already processed TOS content with hash: {content_hash}"
)
continue
all_skipped = False
# Skip incomplete TOS content
check = prompt_check_tos_review(content)
if not check or not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
return review
if all_skipped:
return current_review
return None

View File

@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
{"role": "user", "content": content},
]
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
result_dict = query_openai_json(
messages, model="openai/gemini-2.5-flash-preview-05-20"
)
return cast(TosReviewCheck, result_dict)
@@ -173,12 +175,12 @@ type TosReview = {
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
rating: 'negative' | 'neutral' | 'positive'
}[]
}[] // max 8 highlights, try to provide at least 3.
}
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
Be concise but thorough, and make sure your output is properly formatted JSON.
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
"""
PROMPT_COMMENT_SENTIMENT_SUMMARY = """

View File

@@ -35,9 +35,11 @@ export default defineConfig({
registerType: 'autoUpdate',
manifest: {
name: 'KYCnot.me',
short_name: 'KYCnot.me',
description: 'Find services that respect your privacy',
theme_color: '#040505',
background_color: '#171c1b',
display: 'minimal-ui',
},
pwaAssets: {
image: './public/favicon.svg',
@@ -108,6 +110,8 @@ export default defineConfig({
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
// #endregion
'/service/[...slug]/review': '/service/[...slug]#comments',
},
env: {
schema: {
@@ -121,7 +125,7 @@ export default defineConfig({
}),
// Public URLs (can be accessed from both server and client)
SOURCE_CODE_URL: envField.string({
context: 'server',
context: 'client',
access: 'public',
url: true,
optional: false,

41
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';

View File

@@ -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");

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';

View File

@@ -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)
@@ -576,6 +578,7 @@ enum VerificationStepStatus {
IN_PROGRESS
PASSED
FAILED
WARNING
}
model VerificationStep {

View File

@@ -19,6 +19,7 @@ import {
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
VerificationStepStatus,
} from '@prisma/client'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
@@ -610,6 +611,10 @@ const generateFakeService = (users: User[]) => {
const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
return {
name,
slug,
@@ -643,6 +648,19 @@ const generateFakeService = (users: User[]) => {
},
verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
verificationSteps:
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
? {
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
evidenceMd: faker.lorem.paragraph(),
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
})),
}
: undefined,
referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`,
@@ -661,8 +679,10 @@ const generateFakeService = (users: User[]) => {
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(),
tosReview,
tosReviewAt: tosReview
? faker.date.recent()
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe(
@@ -1123,7 +1143,7 @@ async function main() {
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
Array.from({ length: 570 }, async () => {
const { user } = await createAccount()
return user
})

View File

@@ -22,8 +22,9 @@ 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;
clarification_factor INT := 0;
onion_factor INT := 0;
i2p_factor INT := 0;
monero_factor INT := 0;
@@ -46,6 +47,16 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Adjust score based on KYC level clarification modifiers
SELECT
CASE
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
ELSE 0 -- Default modifier when no clarification or unrecognized value
END
INTO clarification_factor
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
IF EXISTS (
SELECT 1 FROM "Service"
@@ -78,7 +89,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 + clarification_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 +102,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 +137,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
@@ -135,10 +148,21 @@ BEGIN
SET "isRecentlyListed" = FALSE
WHERE id = service_id;
END IF;
-- Apply penalty if ToS cannot be analyzed
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL
) THEN
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));

View File

@@ -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

View File

@@ -126,7 +126,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
@@ -244,7 +245,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,

View File

@@ -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)

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -8,11 +8,13 @@ import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro'
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'
@@ -106,7 +108,11 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
{
!Astro.url.pathname.startsWith('/admin') && (
<ClientRouter />
) /* Disable to prevent bugs in important admin forms */
}
<LoadingIndicator color="green" />
<TailwindJsPluggin />
{htmx && <HtmxScript />}
@@ -137,10 +143,14 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
))
}
<!-- Server events -->
<ServerEventsScript />
{
Astro.locals.user && (
<>
<ServerEventsScript />
<ServiceWorkerScript />
<NotificationEventsScript />
</>
)
}
<!-- Push Notifications -->
<script src="/src/pwa.ts"></script>
<NotificationEventsScript />
<DevToolsMessageScript />

View File

@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
aria-disabled={disabled}
aria-label={label}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>

View File

@@ -150,13 +150,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious}
/>
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<div class="comment-header flex items-center gap-2 text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
<span class="flex min-w-16 items-center gap-1">
<UserBadge
user={comment.author}
size="md"
@@ -179,7 +179,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
<div class="flex w-min grow flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
@@ -240,15 +240,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
comment.author.serviceAffiliations
.filter((affiliation) => affiliation.service.slug === serviceSlug)
.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}
</div>
</div>

View File

@@ -0,0 +1,51 @@
---
---
<script>
import { SOURCE_CODE_URL } from 'astro:env/client'
const logoStyle = `
padding: 0 119.5px;
display: block;
line-height: 64px;
background-size: auto 64px;
background-repeat: no-repeat;
background-position: 50% 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
`
setTimeout(
console.log.bind(
console,
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻‍♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
logoStyle,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
`,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
background: repeating-linear-gradient(
90deg,
#d97706 0%,
#f59e0b 20%,
#f97316 40%,
#ea580c 60%,
#f97316 80%,
#f59e0b 100%
) -100%/ 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
`,
`
font-size: 1rem;
font-family: system-ui, -apple-system, sans-serif;
`
)
)
</script>

View File

@@ -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')

View File

@@ -1,6 +1,7 @@
---
import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn'
@@ -33,6 +34,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 +56,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 +68,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>
)

View File

@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
transition:name="header-admin-link"
text="Admin Dashboard"
position="left"
aria-label="Admin Dashboard"
>
<Icon name="ri:home-gear-line" class="size-10" />
</Tooltip>

View File

@@ -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

View 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>

View File

@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
value: string
disabled?: boolean
}[]
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
selectedValue?: string[] | string
}
const { options, selectProps, ...wrapperProps } = Astro.props
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
<option
value={option.value}
disabled={option.disabled}
selected={
Array.isArray(selectedValue)
? selectedValue.includes(option.value)
: selectedValue === option.value
}
>
{option.label}
</option>
))

View File

@@ -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

View File

@@ -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(

View File

@@ -0,0 +1,151 @@
---
import favicon from '../../public/favicon.svg'
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
import logoNormal from '../assets/logo/logo-normal.svg'
import logoSmall from '../assets/logo/logo-small.svg'
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
import Button from './Button.astro'
import MyPicture from './MyPicture.astro'
const categories: {
title: string
assets: {
name: string
path: typeof logoNormal
alt: string
}[]
}[] = [
{
title: 'Logos',
assets: [
{
name: 'Logo',
path: logoNormal,
alt: 'KYCnot.me logo normal version',
},
{
name: 'Logo small',
path: logoSmall,
alt: 'KYCnot.me logo small version',
},
{
name: 'Logo mini',
path: logoMiniFull,
alt: 'KYCnot.me logo mini version',
},
{
name: 'Logo icon',
path: favicon,
alt: 'KYCnot.me logo icon version',
},
],
},
{
title: 'Review badges',
assets: [
{
name: 'Review badge (long black)',
path: reviewBadgeLongBlack,
alt: 'KYCnot.me review badge long black version',
},
{
name: 'Review badge (long white)',
path: reviewBadgeLongWhite,
alt: 'KYCnot.me review badge long white version',
},
{
name: 'Review badge (short black)',
path: reviewBadgeShortBlack,
alt: 'KYCnot.me review badge short black version',
},
{
name: 'Review badge (short white)',
path: reviewBadgeShortWhite,
alt: 'KYCnot.me review badge short white version',
},
],
},
]
---
<div class="not-prose mb-16 space-y-8">
{
categories.map((category) => {
return (
<>
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
{category.assets.map((asset) => (
<li>
<div
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
style={{
'--transparency-grid-color-1': 'var(--color-night-600)',
'--transparency-grid-color-2': 'var(--color-night-500)',
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
}}
>
<MyPicture
src={asset.path}
alt={asset.alt}
pictureAttributes={{
class: 'contents',
}}
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
/>
</div>
<div class="mt-2 text-center">
<Button
as="a"
href={asset.path.src}
download={asset.name}
label={asset.name}
size="sm"
color="white"
variant="faded"
icon="ri:download-line"
/>
</div>
</li>
))}
</ul>
</>
)
})
}
</div>
<style>
.bg-transparency-grid {
--transparency-grid-color-1: #fff;
--transparency-grid-color-2: #ccc;
--transparency-grid-size: calc(var(--spacing) * 8);
background-color: var(--transparency-grid-color-1);
background-image:
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
),
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
);
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
background-position:
0 0,
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
}
</style>

View File

@@ -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]
)

View File

@@ -0,0 +1,51 @@
---
---
<script>
import { registerSW } from 'virtual:pwa-register'
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)
}
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault()
})
</script>

View File

@@ -3,6 +3,7 @@ import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn'
import TimeFormatted from './TimeFormatted.astro'
@@ -67,7 +68,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
</span>
</div>
) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
@@ -98,14 +99,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
>
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
<span>Some verification steps failed. Please review the details below.</span>
</a>
)
}
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
service.verificationSteps.some((step) => step.status === 'WARNING') && (
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
>
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
<span>Some verification steps are marked as warnings.</span>
</a>
)
}

View File

@@ -108,3 +108,19 @@ export const {
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
)
export const baseScoreType = {
value: 'BASE_SCORE',
slug: 'base-score',
label: 'Base score',
icon: 'ri:information-line',
order: 5,
classNames: {
container: 'bg-night-500',
subcontainer: '',
text: 'text-day-200',
textLight: '',
icon: '',
button: '',
},
} as const satisfies AttributeTypeInfo

View File

@@ -106,7 +106,7 @@ export const {
type: 'matrix',
label: 'Matrix',
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
formatter: ([, value]) => value ?? 'Matrix',
icon: 'ri:hashtag',
urlType: 'url',
},
@@ -121,7 +121,7 @@ export const {
{
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
matcher: /^https?:\/\/(?:www\.)?((?:simplex\.chat|smp\d+\.simplex\.im))\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',

View File

@@ -11,6 +11,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
description: string
classNames: {
dot: string
banner?: string
}
icon: string
color: TailwindColor
@@ -34,6 +35,7 @@ export const {
description: '',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:question-fill',
color: 'gray',
@@ -48,6 +50,7 @@ export const {
description: 'Potential issues that users should be aware of',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'yellow',
@@ -61,6 +64,7 @@ export const {
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'green',
@@ -74,6 +78,7 @@ export const {
description: 'Critical issues affecting service functionality',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'red',
@@ -87,6 +92,7 @@ export const {
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'green',
@@ -100,6 +106,7 @@ export const {
description: 'General information about the service',
classNames: {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
},
icon: 'ri:information-fill',
color: 'sky',
@@ -113,6 +120,7 @@ export const {
description: 'Regular service update or announcement',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:notification-fill',
color: 'green',
@@ -126,6 +134,7 @@ export const {
description: 'Service details were updated on kycnot.me',
classNames: {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
},
icon: 'ri:pencil-fill',
color: 'sky',

View File

@@ -1,13 +1,16 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { KycLevelClarification } from '@prisma/client'
import type { AttributeType, KycLevelClarification } from '@prisma/client'
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
description: string
icon: string
privacyPoints: number
attributeType: AttributeType
}
export const {
@@ -18,22 +21,31 @@ export const {
'value',
(value): KycLevelClarificationInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase().replace('_', '-') : '',
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
description: '',
icon: 'ri:question-line',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
value: 'NONE',
slug: 'none',
label: 'None',
description: 'No clarification needed.',
icon: 'ri:file-copy-line',
privacyPoints: 0,
attributeType: 'INFO',
},
{
value: 'DEPENDS_ON_PARTNERS',
slug: 'depends-on-partners',
label: 'Depends on partners',
description: 'May vary across partners.',
icon: 'ri:share-forward-line',
privacyPoints: -5,
attributeType: 'WARNING',
},
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
)

View File

@@ -2,12 +2,16 @@ import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { parseIntWithFallback } from '../lib/numbers'
import { transformCase } from '../lib/strings'
import type { AttributeType } from '@prisma/client'
type KycLevelInfo<T extends string | null | undefined = string> = {
id: T
value: number
icon: string
name: string
description: string
privacyPoints: number
attributeType: AttributeType
}
export const {
@@ -22,6 +26,8 @@ export const {
icon: 'diamond-question',
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
description: '',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
@@ -30,6 +36,8 @@ export const {
icon: 'anonymous-mask',
name: 'Guaranteed no KYC',
description: 'Terms explicitly state KYC will never be requested.',
privacyPoints: 25,
attributeType: 'GOOD',
},
{
id: '1',
@@ -37,6 +45,8 @@ export const {
icon: 'diamond-question',
name: 'No KYC mention',
description: 'No mention of current or future KYC requirements.',
privacyPoints: 15,
attributeType: 'GOOD',
},
{
id: '2',
@@ -45,6 +55,8 @@ export const {
name: 'KYC on authorities request',
description:
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
privacyPoints: -5,
attributeType: 'WARNING',
},
{
id: '3',
@@ -52,6 +64,8 @@ export const {
icon: 'gun',
name: 'Shotgun KYC',
description: 'May request KYC and block funds based on automated triggers.',
privacyPoints: -15,
attributeType: 'WARNING',
},
{
id: '4',
@@ -59,6 +73,8 @@ export const {
icon: 'fingerprint-detailed',
name: 'Mandatory KYC',
description: 'Required for key features and can be required arbitrarily at any time.',
privacyPoints: -25,
attributeType: 'BAD',
},
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
)

View File

@@ -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',

View File

@@ -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,

View File

@@ -42,6 +42,12 @@ export const {
icon: 'ri:alert-line',
color: 'red',
},
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
},
{
value: 'PENDING',
label: 'Pending',

1
web/src/env.d.ts vendored
View File

@@ -17,6 +17,7 @@ declare global {
interface Window {
htmx?: typeof htmx
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
namespace PrismaJson {

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
---
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
import TimeFormatted from '../components/TimeFormatted.astro'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import BaseLayout from './BaseLayout.astro'
@@ -13,21 +14,19 @@ type Props = ComponentProps<typeof BaseLayout> &
MarkdownLayoutProps<{
children: AstroChildren
title: string
author: string
pubDate: string
updatedAt?: string
description: string
icon?: string
}>
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
const publishDate = frontmatter.updatedAt ? new Date(frontmatter.updatedAt) : null
const ogImageTemplateData = {
template: 'generic',
title: frontmatter.title,
description: frontmatter.description,
icon: frontmatter.icon,
} satisfies OgImageAllTemplatesWithProps
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
---
<BaseLayout
@@ -44,14 +43,7 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
datePublished: publishDate?.toISOString(),
dateModified: publishDate?.toISOString(),
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
author: frontmatter.author
? weAreAuthor
? KYCNOTME_SCHEMA_MINI
: {
'@type': 'Person',
name: frontmatter.author,
}
: undefined,
author: KYCNOTME_SCHEMA_MINI,
publisher: KYCNOTME_SCHEMA_MINI,
mainEntityOfPage: {
'@type': 'WebPage',
@@ -61,15 +53,26 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
...(schemas ?? []),
]}
>
<div class="bg-dots-fade absolute inset-x-0 top-0 -z-1 h-128 opacity-15"></div>
<div
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
>
<h1>{frontmatter.title}</h1>
<p class="text-gray-500">
{frontmatter.author && `by ${frontmatter.author}`}
{frontmatter.pubDate && ` | ${new Date(frontmatter.pubDate).toLocaleDateString()}`}
<h1 class="mb-0!">{frontmatter.title}</h1>
<p class="mt-2! opacity-70">
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
</p>
<slot />
</div>
</BaseLayout>
<style>
.bg-dots-fade {
background:
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
linear-gradient(to bottom, #888, #fff);
background-blend-mode: multiply;
mix-blend-mode: multiply;
filter: contrast(21);
}
</style>

View File

@@ -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>
)
}

View File

@@ -2,6 +2,8 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
@@ -9,6 +11,271 @@ 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
}[]
}
type NonDbAttributeFull = 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
kycLevel: true
kycLevelClarification: true
}
}>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
show: boolean
}
}
export const nonDbAttributes: NonDbAttributeFull[] = [
{
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).`,
}),
},
...kycLevels.map<NonDbAttributeFull>((kycLevel) => ({
slug: `kyc-level-${kycLevel.id}`,
title: kycLevel.name,
type: kycLevel.attributeType,
category: 'PRIVACY',
description: kycLevel.description,
privacyPoints: kycLevel.privacyPoints,
trustPoints: 0,
links: [
{
url: `/?max-kyc=${kycLevel.id}`,
label: 'With this or better',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.kycLevel === kycLevel.value,
}),
})),
...kycLevelClarifications
.filter((clarification) => clarification.value !== 'NONE')
.map<NonDbAttributeFull>((clarification) => ({
slug: `kyc-clarification-${clarification.slug}`,
title: `KYC ${clarification.label}`,
type: clarification.attributeType,
category: 'PRIVACY',
description: clarification.description,
privacyPoints: clarification.privacyPoints,
trustPoints: 0,
links: [],
customize: (service) => ({
show: service.kycLevelClarification === clarification.value,
}),
})),
{
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: {
@@ -34,131 +301,15 @@ export function sortAttributes<
}
export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
createdAt: true
}
}>,
service: Parameters<NonDbAttributeFull['customize']>[0],
{ 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: [],
},
]
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
View 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 } }
}

View File

@@ -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 [

View File

@@ -53,6 +53,7 @@ export async function sendNotification(
aboutServiceSuggestion: {
select: {
status: true,
type: true,
service: {
select: {
name: true,

View File

@@ -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 {

View File

@@ -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
),
{

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
---
layout: ../layouts/MarkdownLayout.astro
title: About
author: KYCnot.me
pubDate: 2025-05-15
updatedAt: 2025-05-15
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
icon: 'ri:information-line'
---
@@ -57,7 +56,6 @@ Some users are **verified**, this means that the moderators have confirmed that
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
## Listings
### Suggesting a new listing
@@ -141,7 +139,7 @@ You can view all available attributes on the [Attributes page](/attributes).
Attributes are classified into two main types:
1. **Privacy Attributes** Related to data protection and anonymity.
2. **Trust Attributes** Related to reliability and security.
1. **Trust Attributes** Related to reliability and security.
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
@@ -154,31 +152,16 @@ Scores are calculated **automatically** using clear, fixed rules. We do not chan
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points.
2. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points**
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
7. **Final Score Range:** The final score is always kept between 0 and 100.
1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
##### Trust Score
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points.
2. **Verification Status Adjustment:**
- **Verification Success:** +10 points
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
5. **Final Score Range:** The final score is always kept between 0 and 100.
1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
##### Overall Score
@@ -239,6 +222,10 @@ You can contact via direct chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
## Downloads and assets
For logos and brand assets, visit our [downloads page](/downloads).
## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.

View File

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

View File

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

View File

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

View File

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

View File

@@ -173,7 +173,7 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
label: status.label,
value: status.value,
}))}
selectProps={{ value: serviceSuggestion.status }}
selectedValue={serviceSuggestion.status}
class="flex-1"
error={serviceSuggestionUpdateInputErrors.status}
/>

View File

@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
/>
<InputTextArea
label="ToS URLs"
description="One per line"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
@@ -801,7 +801,8 @@ const apiCalls = await Astro.locals.banners.try(
label: type.label,
value: type.id,
}))}
selectProps={{ required: true, value: event.type }}
selectedValue={event.type}
selectProps={{ required: true }}
error={eventUpdateInputErrors.type}
/>
</div>
@@ -982,7 +983,7 @@ const apiCalls = await Astro.locals.banners.try(
label: status.label,
value: status.value,
}))}
selectProps={{ value: step.status }}
selectedValue={step.status}
error={verificationStepUpdateInputErrors.status}
/>

View File

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

73
web/src/pages/assets.mdx Normal file
View File

@@ -0,0 +1,73 @@
---
layout: ../layouts/MarkdownLayout.astro
title: Assets & Downloads
author: KYCnot.me
pubDate: 2025-06-11
description: 'Download KYCnot.me logos and assets.'
icon: 'ri:image-line'
---
import PressAssets from '../components/PressAssets.astro'
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
<PressAssets
title="KYCnot.me Brand Kit"
description="Complete collection of our logos and 'Review on KYCnot.me' badges in various formats and styles."
zipPath="/kycnotme-press-assets.zip"
assets={[
{
name: 'Logo - Normal',
path: '/logo/logo-normal.svg',
alt: 'KYCnot.me logo normal version',
category: 'logo',
},
{
name: 'Logo - Small',
path: '/logo/logo-small.svg',
alt: 'KYCnot.me logo small version',
category: 'logo',
},
{
name: 'Logo - Mini',
path: '/logo/logo-mini.svg',
alt: 'KYCnot.me logo mini version',
category: 'logo',
},
{
name: 'Logo - Mini Full',
path: '/logo/logo-mini-full.svg',
alt: 'KYCnot.me logo mini full version',
category: 'logo',
},
{
name: 'Badge - Long Black',
path: '/review-on-kycnotme/long-black.svg',
alt: 'Review on KYCnot.me badge - long black version',
category: 'badge',
},
{
name: 'Badge - Long White',
path: '/review-on-kycnotme/long-white.svg',
alt: 'Review on KYCnot.me badge - long white version',
category: 'badge',
},
{
name: 'Badge - Short Black',
path: '/review-on-kycnotme/short-black.svg',
alt: 'Review on KYCnot.me badge - short black version',
category: 'badge',
},
{
name: 'Badge - Short White',
path: '/review-on-kycnotme/short-white.svg',
alt: 'Review on KYCnot.me badge - short white version',
category: 'badge',
},
]}
/>
## Brand design
- Brand color: `#3bdb78`
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).

View File

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

View File

@@ -1,13 +1,12 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
updatedAt: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
import { SOURCE_CODE_URL } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'

View File

@@ -3,8 +3,7 @@ layout: ../../layouts/MarkdownLayout.astro
title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line'
author: KYCnot.me
pubDate: 2025-05-15
updatedAt: 2025-05-15
---
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'

View File

@@ -0,0 +1,21 @@
---
layout: ../layouts/MarkdownLayout.astro
title: Downloads and assets
author: KYCnot.me
updatedAt: 2025-06-12
description: 'Download KYCnot.me logos and assets.'
icon: 'ri:image-line'
---
import PressAssets from '../components/PressAssets.astro'
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
<PressAssets />
Review service link format: `https://kycnot.me/service/[slug]/review`
## Brand design
- Brand color: `#3bdb78`
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import { head, orderBy, pick, shuffle, sortBy, tail } from 'lodash-es'
import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandard from '../../components/BadgeStandard.astro'
import Button from '../../components/Button.astro'
import CommentSection from '../../components/CommentSection.astro'
import CommentSummary from '../../components/CommentSummary.astro'
import DropdownButton from '../../components/DropdownButton.astro'
@@ -28,7 +27,7 @@ import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { baseScoreType, getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes'
@@ -295,6 +294,8 @@ const statusIcon = {
APPROVED: undefined,
}[service.verificationStatus]
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
const shuffledLinks = {
clearnet: shuffle(service.serviceUrls),
onion: shuffle(service.onionUrls),
@@ -386,6 +387,13 @@ const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
color: 'red',
timelineIconClass: 'text-red-400',
} as const
case VerificationStepStatus.WARNING:
return {
text: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
timelineIconClass: 'text-yellow-400',
} as const
default:
return {
text: 'Unknown',
@@ -408,9 +416,14 @@ const ogImageTemplateData = {
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
const activeAlertOrWarningEvents = service.events
.map((event) => ({
...event,
typeInfo: getEventTypeInfo(event.type),
}))
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
---
<BaseLayout
@@ -512,27 +525,18 @@ const activeAlertOrWarningEvents = service.events.filter(
]}
>
{
activeAlertOrWarningEvents.length > 0 && (
!!activeEventToShow && (
<a
href="#events"
class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
activeEventToShow.typeInfo.classNames.banner
)}
>
<Icon
name={
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'There is an active alert for this service. Click to see details.'
: 'There is an active warning for this service. Click to see details.'}
<Icon name={activeEventToShow.typeInfo.icon} class="me-1.5 inline-block size-4 align-[-0.15em]" />
<span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
{activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
<span class="underline">Go to events</span>
</a>
)
}
@@ -761,11 +765,18 @@ const activeAlertOrWarningEvents = service.events.filter(
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
{shownLinks.map((url) => (
<li>
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li>
))}
@@ -789,11 +800,18 @@ const activeAlertOrWarningEvents = service.events.filter(
{hiddenLinks.map((url) => (
<li class="hidden peer-checked:block">
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li>
))}
</ul>
@@ -1055,10 +1073,27 @@ const activeAlertOrWarningEvents = service.events.filter(
</li>
)
})}
<li
class={cn(
'bg-night-400 flex items-center self-start rounded-md p-2 text-sm text-pretty select-none',
baseScoreType.classNames.container,
baseScoreType.classNames.text
)}
>
<Icon
name={baseScoreType.icon}
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
/>
<span class="font-title">{baseScoreType.label}</span>
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
</li>
</ul>
)
}
<p class="text-day-400 mt-3 text-center text-xs">
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Rounded)</span>
</p>
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
<a
href="/about#service-scores"
@@ -1072,7 +1107,7 @@ const activeAlertOrWarningEvents = service.events.filter(
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
>
<Icon name="ri:information-line" class="size-3" />
Attributes list
All attributes list
</a>
</div>
@@ -1081,105 +1116,119 @@ const activeAlertOrWarningEvents = service.events.filter(
</h2>
{
service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? (
service.tosReview ? (
<>
<Schema
item={
{
'@context': 'https://schema.org',
'@type': 'Review',
itemReviewed: { '@id': itemReviewedId },
reviewAspect: 'Terms of Service',
name: 'Terms of Service Review',
reviewBody: service.tosReview.summary,
datePublished: service.tosReviewAt?.toISOString(),
author: KYCNOTME_SCHEMA_MINI,
positiveNotes: service.tosReview.highlights
.filter((h) => h.rating === 'positive')
.map(
(h, i) =>
({
'@type': 'ListItem',
position: i + 1,
name: h.title,
description: h.content,
}) satisfies ListItem
),
negativeNotes: service.tosReview.highlights
.filter((h) => h.rating === 'negative')
.map(
(h, i) =>
({
'@type': 'ListItem',
position: i + 1,
name: h.title,
description: h.content,
}) satisfies ListItem
),
} satisfies WithContext<Review>
}
/>
service.tosReviewAt !== null ? (
service.tosReview ? (
<>
<Schema
item={
{
'@context': 'https://schema.org',
'@type': 'Review',
itemReviewed: { '@id': itemReviewedId },
reviewAspect: 'Terms of Service',
name: 'Terms of Service Review',
reviewBody: service.tosReview.summary,
datePublished: service.tosReviewAt.toISOString(),
author: KYCNOTME_SCHEMA_MINI,
positiveNotes: service.tosReview.highlights
.filter((h) => h.rating === 'positive')
.map(
(h, i) =>
({
'@type': 'ListItem',
position: i + 1,
name: h.title,
description: h.content,
}) satisfies ListItem
),
negativeNotes: service.tosReview.highlights
.filter((h) => h.rating === 'negative')
.map(
(h, i) =>
({
'@type': 'ListItem',
position: i + 1,
name: h.title,
description: h.content,
}) satisfies ListItem
),
} satisfies WithContext<Review>
}
/>
<div class="flex flex-col sm:flex-row sm:gap-6">
<div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4">
{service.tosReview.summary ? (
<Markdown content={service.tosReview.summary} />
) : (
<p>No summary available</p>
)}
</div>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{sortBy(
service.tosReview.highlights.map((highlight) => ({
...highlight,
ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
<div class="flex flex-col sm:flex-row sm:gap-6">
<div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4">
{service.tosReview.summary ? (
<Markdown content={service.tosReview.summary} />
) : (
<p>No summary available</p>
)}
</div>
))}
</div>
</div>
<p class="text-day-400 mt-4 text-center text-xs">
{!!service.tosReviewAt && (
<>
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
</>
)}
{!!service.tosReviewAt && !!service.tosUrls.length && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
<div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{sortBy(
service.tosReview.highlights.map((highlight) => ({
...highlight,
ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
)}
</div>
))}
</div>
<p class="text-day-400 mt-4 text-center text-xs">
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.length > 0 && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
) : (
<div class="text-day-400 my-12 text-center">
<p>Can't analyze ToS with AI</p>
<p class="text-day-500 mt-1 text-sm text-balance">
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
</p>
<p class="mt-2 text-xs">
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.length > 0 && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="hover:underline">
{urlDomain(url)}
</a>
))}
</p>
</div>
)
) : (
<div class="text-day-400 my-12 text-center">
<p>Not reviewed yet</p>
@@ -1332,77 +1381,6 @@ const activeAlertOrWarningEvents = service.events.filter(
</div>
)
}
{
service.verificationSteps.length > 0 && (
<div class="border-night-500 mt-2 border-t pt-3">
<h3 class="font-title mb-2 flex items-center gap-1 text-sm font-semibold">
<Icon name="ri:checkbox-multiple-line" class="size-4" />
Verification Steps
</h3>
<ul class="grid gap-2 text-xs">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li>
<details class="group/step">
<summary class="border-night-600 hover:bg-night-700/50 relative flex cursor-pointer items-center gap-2 rounded p-1.5 select-none">
<div
class={cn('h-1.5 w-1.5 rounded-full', {
'bg-gray-400 shadow-[0_0_3px_1px_rgba(156,163,175,0.7)]':
step.status === VerificationStepStatus.PENDING,
'bg-blue-400 shadow-[0_0_4px_1px_rgba(96,165,250,0.8)]':
step.status === VerificationStepStatus.IN_PROGRESS,
'bg-green-400 shadow-[0_0_4px_1px_rgba(34,197,94,0.8)]':
step.status === VerificationStepStatus.PASSED,
'bg-red-400 shadow-[0_0_4px_1px_rgba(248,113,113,0.8)]':
step.status === VerificationStepStatus.FAILED,
})}
/>
<div class="flex flex-1 items-center">
<strong class="mr-1 font-medium text-white">{step.title}</strong>
<span class="text-day-400 mr-1">•</span>
<span
class={cn('mr-2 font-bold', {
'text-gray-400': step.status === VerificationStepStatus.PENDING,
'text-blue-400': step.status === VerificationStepStatus.IN_PROGRESS,
'text-green-400': step.status === VerificationStepStatus.PASSED,
'text-red-400': step.status === VerificationStepStatus.FAILED,
})}
>
{statusInfo.text}
</span>
<Icon
name="ri:arrow-down-s-line"
class="text-day-400 size-3.5 transition-transform group-open/step:rotate-180"
/>
</div>
</summary>
<div class="border-night-600 mt-1 ml-3.5 border-l pb-1 pl-3">
<p class="text-day-400 text-2xs mb-1 font-medium">
Added
<TimeFormatted date={step.createdAt} hourPrecision />
</p>
{step.description && (
<div class="text-day-300 my-1 text-xs">{step.description}</div>
)}
{step.evidenceMd && (
<div class="bg-night-900 mt-2 rounded p-2">
<div class="text-day-400 text-2xs mb-1 font-medium">Evidence:</div>
<div class="prose prose-invert prose-xs prose-img:max-w-full text-[11px] leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
</div>
)}
</div>
</details>
</li>
)
})}
</ul>
</div>
)
}
</div>
<div class="flex flex-col gap-2">
@@ -1456,94 +1434,70 @@ const activeAlertOrWarningEvents = service.events.filter(
</form>
)
}
{
service.verificationProofMd && (
<Button
as="label"
for="show-verification-proof"
icon="ri:file-text-line"
label="Verification audit"
color="white"
/>
)
}
</div>
</div>
{
service.verificationProofMd && (
<>
<input type="checkbox" id="show-verification-proof" class="peer sr-only" />
<div class="bg-night-800 mt-4 hidden rounded-md p-4 peer-checked:block" id="verification-proof">
<div class="flex items-center justify-between">
<h3 class="font-title text-lg font-semibold">Verification proof</h3>
<Button as="label" for="show-verification-proof" label="Close" icon="ri:close-line" size="sm" />
</div>
<div class="prose prose-invert prose-sm mt-4 max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
{service.verificationSteps.length > 0 && (
<div class="mt-6">
<h4 class="font-title text-md mb-3 font-semibold">Verification Steps</h4>
<ul class="-mb-4">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="flex pb-4">
<div class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center gap-1.5">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
class={cn(
{ 'animate-spin': statusInfo.icon === 'ri:loader-4-line' },
'shrink-0'
)}
/>
<strong class="text-sm leading-tight font-semibold text-white">
{step.title}
</strong>
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<details class="group/evidence mt-2 text-xs">
<summary class="text-day-400 hover:text-day-200 cursor-pointer py-0.5 text-[11px] font-medium">
Show Evidence
</summary>
<div class="prose prose-invert prose-xs bg-night-900 mt-1.5 rounded p-2 text-[11px] leading-relaxed group-open/evidence:block">
<Markdown content={step.evidenceMd} />
</div>
</details>
)}
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
</>
)
}
</div>
{
service.verificationProofMd && (
<>
<h3 class="font-title mt-4 text-lg font-semibold">Verification proof</h3>
<div class="prose prose-invert prose-sm max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
</>
)
}
{
service.verificationSteps.length > 0 && (
<>
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
<ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="text-sm leading-tight font-semibold text-white">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
classNames={{
icon: cn(statusInfo.icon === 'ri:loader-4-line' && 'animate-spin'),
}}
class="me-1"
/>
{step.title}
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<div class="prose prose-invert prose-xs mt-2 block max-w-none text-xs leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
)}
</li>
)
})}
</ul>
</>
)
}
<h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments">
Comments
</h2>
<div
id="discuss"
class='grid grid-cols-1 gap-8 [grid-template-areas:"about""rating""ai"] sm:grid-cols-[1fr_1fr] sm:gap-4 sm:[grid-template-areas:"about_ai""rating_ai"]'
>
<div class="relative rounded-md bg-orange-400/10 p-2 px-2.5 text-xs text-orange-100/70 [grid-area:about]">

View File

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

View File

@@ -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)
},
})

View File

@@ -103,10 +103,6 @@
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
}
@utility scrollbar-w-none {
scrollbar-width: none;
}
@utility checkbox-force-checked {
&:not(:checked) {
@apply border-transparent! bg-current/50!;