Release 202506101742
This commit is contained in:
@@ -332,7 +332,7 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
|||||||
return remove_service_attribute(service_id, attribute_id)
|
return remove_service_attribute(service_id, attribute_id)
|
||||||
|
|
||||||
|
|
||||||
def save_tos_review(service_id: int, review: TosReviewType):
|
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||||
"""
|
"""
|
||||||
Save a TOS review for a specific service.
|
Save a TOS review for a specific service.
|
||||||
|
|
||||||
@@ -341,8 +341,8 @@ def save_tos_review(service_id: int, review: TosReviewType):
|
|||||||
review: A TypedDict containing the review data.
|
review: A TypedDict containing the review data.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Serialize the dictionary to a JSON string for the database
|
# Only serialize to JSON if review is not None
|
||||||
review_json = json.dumps(review)
|
review_json = json.dumps(review) if review is not None else None
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
with conn.cursor(row_factory=dict_row) as cursor:
|
with conn.cursor(row_factory=dict_row) as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Task for retrieving Terms of Service (TOS) text.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, Literal
|
||||||
|
|
||||||
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
||||||
from pyworker.tasks.base import Task
|
from pyworker.tasks.base import Task
|
||||||
@@ -52,65 +52,71 @@ class TosReviewTask(Task):
|
|||||||
)
|
)
|
||||||
self.logger.info(f"TOS URLs: {tos_urls}")
|
self.logger.info(f"TOS URLs: {tos_urls}")
|
||||||
|
|
||||||
|
review = self.get_tos_review(tos_urls, service.get("tosReview"))
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
for tos_url in tos_urls:
|
||||||
api_url = f"{tos_url}"
|
api_url = f"{tos_url}"
|
||||||
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
||||||
|
|
||||||
# Sleep for 1 second to avoid rate limiting
|
|
||||||
content = fetch_markdown(api_url)
|
content = fetch_markdown(api_url)
|
||||||
|
|
||||||
if content:
|
if not content:
|
||||||
# Hash the content to avoid repeating the same content
|
self.logger.warning(f"Failed to retrieve TOS content for URL: {tos_url}")
|
||||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
all_skipped = False
|
||||||
self.logger.info(f"Content hash: {content_hash}")
|
continue
|
||||||
|
|
||||||
# service.get("tosReview") can be None if the DB field is NULL.
|
# Hash the content to avoid repeating the same content
|
||||||
# Default to an empty dict to prevent AttributeError on .get()
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||||
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
|
self.logger.info(f"Content hash: {content_hash}")
|
||||||
"tosReview"
|
|
||||||
)
|
# Skip processing if we've seen this content before
|
||||||
tos_review: Dict[str, Any] = (
|
if current_review and current_review.get("contentHash") == content_hash:
|
||||||
tos_review_data_from_service
|
self.logger.info(
|
||||||
if tos_review_data_from_service is not None
|
f"Skipping already processed TOS content with hash: {content_hash}"
|
||||||
else {}
|
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
stored_hash = tos_review.get("contentHash")
|
all_skipped = False
|
||||||
|
|
||||||
# Skip processing if we've seen this content before
|
# Skip incomplete TOS content
|
||||||
if stored_hash == content_hash:
|
check = prompt_check_tos_review(content)
|
||||||
self.logger.info(
|
if not check or not check["isComplete"]:
|
||||||
f"Skipping already processed TOS content with hash: {content_hash}"
|
continue
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip incomplete TOS content
|
# Query OpenAI to summarize the content
|
||||||
check = prompt_check_tos_review(content)
|
review = prompt_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
|
|
||||||
|
|
||||||
|
if review:
|
||||||
|
review["contentHash"] = content_hash
|
||||||
return review
|
return review
|
||||||
else:
|
|
||||||
self.logger.warning(
|
if all_skipped:
|
||||||
f"Failed to retrieve TOS content for URL: {tos_url}"
|
return current_review
|
||||||
)
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ type TosReview = {
|
|||||||
|
|
||||||
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
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.
|
Do not provide more than 8 highlights. Focus on the most important information for the user. Be concise but thorough, and make sure your output is properly formatted JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||||
|
|||||||
41
web/package-lock.json
generated
41
web/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@astrojs/db": "0.15.0",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.3.0",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.2",
|
"@astrojs/node": "9.2.2",
|
||||||
|
"@astrojs/rss": "4.0.12",
|
||||||
"@astrojs/sitemap": "3.4.1",
|
"@astrojs/sitemap": "3.4.1",
|
||||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/inter": "5.2.5",
|
"@fontsource/inter": "5.2.5",
|
||||||
@@ -320,6 +321,16 @@
|
|||||||
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
|
"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": {
|
"node_modules/@astrojs/sitemap": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.4.1.tgz",
|
||||||
@@ -9789,6 +9800,24 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
||||||
@@ -16567,6 +16596,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@astrojs/db": "0.15.0",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.3.0",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.2",
|
"@astrojs/node": "9.2.2",
|
||||||
|
"@astrojs/rss": "4.0.12",
|
||||||
"@astrojs/sitemap": "3.4.1",
|
"@astrojs/sitemap": "3.4.1",
|
||||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/inter": "5.2.5",
|
"@fontsource/inter": "5.2.5",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;
|
||||||
@@ -135,6 +135,7 @@ enum NotificationType {
|
|||||||
COMMUNITY_NOTE_ADDED
|
COMMUNITY_NOTE_ADDED
|
||||||
/// Comment that is not a reply. May include a rating.
|
/// Comment that is not a reply. May include a rating.
|
||||||
ROOT_COMMENT_CREATED
|
ROOT_COMMENT_CREATED
|
||||||
|
SUGGESTION_CREATED
|
||||||
SUGGESTION_MESSAGE
|
SUGGESTION_MESSAGE
|
||||||
SUGGESTION_STATUS_CHANGE
|
SUGGESTION_STATUS_CHANGE
|
||||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
// 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)
|
moderator Boolean @default(false)
|
||||||
verifiedLink String?
|
verifiedLink String?
|
||||||
secretTokenHash String @unique
|
secretTokenHash String @unique
|
||||||
|
feedId String @unique @default(cuid(2))
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
totalKarma Int @default(0)
|
totalKarma Int @default(0)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
|||||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
privacy_score INT := 50; -- Start from middle value (50)
|
privacy_score INT := 0;
|
||||||
kyc_factor INT;
|
kyc_factor INT;
|
||||||
onion_factor INT := 0;
|
onion_factor INT := 0;
|
||||||
i2p_factor INT := 0;
|
i2p_factor INT := 0;
|
||||||
@@ -78,7 +78,7 @@ BEGIN
|
|||||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||||
|
|
||||||
-- Calculate final privacy score (base 100)
|
-- Calculate final privacy score (base 100)
|
||||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
privacy_score := 50 + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||||
|
|
||||||
-- Ensure the score is in reasonable bounds (0-100)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||||
@@ -91,9 +91,11 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
trust_score INT := 50; -- Start from middle value (50)
|
trust_score INT := 0;
|
||||||
verification_factor INT;
|
verification_factor INT;
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
|
recently_listed_factor INT := 0;
|
||||||
|
tos_penalty_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -124,7 +126,7 @@ BEGIN
|
|||||||
AND "verificationStatus" = 'APPROVED'
|
AND "verificationStatus" = 'APPROVED'
|
||||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||||
) THEN
|
) THEN
|
||||||
trust_score := trust_score - 10;
|
recently_listed_factor := -10;
|
||||||
-- Update the isRecentlyListed flag to true
|
-- Update the isRecentlyListed flag to true
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = TRUE
|
SET "isRecentlyListed" = TRUE
|
||||||
@@ -144,12 +146,12 @@ BEGIN
|
|||||||
AND "tosReviewAt" IS NOT NULL
|
AND "tosReviewAt" IS NOT NULL
|
||||||
AND "tosReview" IS NULL
|
AND "tosReview" IS NULL
|
||||||
) THEN
|
) THEN
|
||||||
trust_score := trust_score - 3;
|
tos_penalty_factor := -3;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Calculate final trust score (base 100)
|
-- 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)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||||
BEGIN
|
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)
|
-- Notify suggestion author (if not the sender)
|
||||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||||
@@ -55,6 +68,13 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ 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
|
-- Trigger for new messages
|
||||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
id: {
|
id: {
|
||||||
in: matches.map(({ id }) => id),
|
in: matches.map(({ id }) => id),
|
||||||
},
|
},
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -58,6 +62,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
|
|||||||
serializedValue = value
|
serializedValue = value
|
||||||
} else if (value === undefined || value === null) {
|
} else if (value === undefined || value === null) {
|
||||||
serializedValue = ''
|
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') {
|
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
serializedValue = value.toString()
|
serializedValue = value.toString()
|
||||||
@@ -144,17 +150,7 @@ export const serviceSuggestionActions = {
|
|||||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
.regex(/^[a-z0-9-]+$/, {
|
||||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
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),
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
@@ -189,8 +185,16 @@ export const serviceSuggestionActions = {
|
|||||||
location: 'serviceSuggestion.createService',
|
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) {
|
if (!input.skipDuplicateCheck) {
|
||||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
const possibleDuplicates = [
|
||||||
|
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
||||||
|
...(await findPossibleDuplicates(input)),
|
||||||
|
]
|
||||||
|
|
||||||
if (possibleDuplicates.length > 0) {
|
if (possibleDuplicates.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -208,6 +212,13 @@ export const serviceSuggestionActions = {
|
|||||||
service: undefined,
|
service: undefined,
|
||||||
} as const
|
} 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)
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import HtmxScript from './HtmxScript.astro'
|
|||||||
import NotificationEventsScript from './NotificationEventsScript.astro'
|
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||||
import { makeOgImageUrl } from './OgImage'
|
import { makeOgImageUrl } from './OgImage'
|
||||||
import ServerEventsScript from './ServerEventsScript.astro'
|
import ServerEventsScript from './ServerEventsScript.astro'
|
||||||
|
import ServiceWorkerScript from './ServiceWorkerScript.astro'
|
||||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||||
|
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
@@ -137,10 +138,12 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Server events -->
|
{
|
||||||
<ServerEventsScript />
|
Astro.locals.user && (
|
||||||
|
<>
|
||||||
<!-- Push Notifications -->
|
<ServerEventsScript />
|
||||||
|
<ServiceWorkerScript />
|
||||||
<script src="/src/pwa.ts"></script>
|
<NotificationEventsScript />
|
||||||
<NotificationEventsScript />
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function addBadgeIfUnread(href: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('sse-new-notification', () => {
|
document.addEventListener('sse:new-notification', () => {
|
||||||
const links = document.querySelectorAll('link[rel="icon"]')
|
const links = document.querySelectorAll('link[rel="icon"]')
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
const href = link.getAttribute('href')
|
const href = link.getAttribute('href')
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const links = [
|
|||||||
icon: 'ri:plug-line',
|
icon: 'ri:plug-line',
|
||||||
external: false,
|
external: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/feeds',
|
||||||
|
label: 'RSS',
|
||||||
|
icon: 'ri:rss-line',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/about',
|
href: '/about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
@@ -49,7 +55,10 @@ const links = [
|
|||||||
const { class: className, ...htmlProps } = Astro.props
|
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(
|
links.map(
|
||||||
({ href, label, icon, external }) =>
|
({ href, label, icon, external }) =>
|
||||||
@@ -58,9 +67,9 @@ const { class: className, ...htmlProps } = Astro.props
|
|||||||
href={href}
|
href={href}
|
||||||
target={external ? '_blank' : undefined}
|
target={external ? '_blank' : undefined}
|
||||||
rel={external ? 'noopener noreferrer' : 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}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const count =
|
|||||||
}
|
}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('sse-new-notification', () => {
|
document.addEventListener('sse:new-notification', () => {
|
||||||
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
|
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
|
||||||
const currentCount = Number(link.getAttribute('data-current-count') || 0)
|
const currentCount = Number(link.getAttribute('data-current-count') || 0)
|
||||||
const newCount = currentCount + 1
|
const newCount = currentCount + 1
|
||||||
|
|||||||
61
web/src/components/InputCheckbox.astro
Normal file
61
web/src/components/InputCheckbox.astro
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type InputWrapper from './InputWrapper.astro'
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||||
|
disabled?: boolean
|
||||||
|
id?: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
label: string
|
||||||
|
children?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
label?: undefined
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { disabled, name, required, error, id, label } = Astro.props
|
||||||
|
|
||||||
|
const hasError = !!error && error.length > 0
|
||||||
|
---
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'inline-flex cursor-pointer items-center gap-2',
|
||||||
|
hasError && 'text-red-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
transition:persist
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
class={cn(disabled && 'opacity-50')}
|
||||||
|
/>
|
||||||
|
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{
|
||||||
|
hasError &&
|
||||||
|
(typeof error === 'string' ? (
|
||||||
|
<p class="text-sm text-red-500">{error}</p>
|
||||||
|
) : (
|
||||||
|
<ul class="text-sm text-red-500">
|
||||||
|
{error.map((e) => (
|
||||||
|
<li>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
class={cn(
|
class={cn(
|
||||||
baseInputClassNames.input,
|
baseInputClassNames.input,
|
||||||
baseInputClassNames.textarea,
|
baseInputClassNames.textarea,
|
||||||
|
!!inputProps?.rows && 'h-auto',
|
||||||
inputProps?.class,
|
inputProps?.class,
|
||||||
hasError && baseInputClassNames.error,
|
hasError && baseInputClassNames.error,
|
||||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||||
import { makeNotificationOptions } from '../lib/notificationOptions'
|
import { makeNotificationOptions } from '../lib/notificationOptions'
|
||||||
|
|
||||||
document.addEventListener('sse-new-notification', (event) => {
|
document.addEventListener('sse:new-notification', (event) => {
|
||||||
if (isBrowserNotificationsEnabled()) {
|
if (isBrowserNotificationsEnabled()) {
|
||||||
const payload = event.detail
|
const payload = event.detail
|
||||||
const notification = showBrowserNotification(
|
const notification = showBrowserNotification(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ if (!Astro.locals.user) return
|
|||||||
const data = JSON.parse(event.data as string)
|
const data = JSON.parse(event.data as string)
|
||||||
|
|
||||||
if (isServerEventsEvent(data)) {
|
if (isServerEventsEvent(data)) {
|
||||||
const eventType = `sse-${data.type}` as const
|
const eventType = `sse:${data.type}` as const
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
|
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
|
||||||
)
|
)
|
||||||
|
|||||||
54
web/src/components/ServiceWorkerScript.astro
Normal file
54
web/src/components/ServiceWorkerScript.astro
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
|
interface Window {
|
||||||
|
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
|
||||||
|
|
||||||
|
let hasPendingUpdate = false
|
||||||
|
|
||||||
|
const updateSW = registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegisteredSW: (_swScriptUrl, registration) => {
|
||||||
|
if (registration) window.__SW_REGISTRATION__ = registration
|
||||||
|
|
||||||
|
document.addEventListener('astro:after-swap', checkAndApplyPendingUpdate, { passive: true })
|
||||||
|
window.addEventListener('popstate', checkAndApplyPendingUpdate, { passive: true })
|
||||||
|
},
|
||||||
|
onNeedRefresh: () => {
|
||||||
|
if (shouldSkipAutoReload()) {
|
||||||
|
void updateSW(false)
|
||||||
|
hasPendingUpdate = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSW(true)
|
||||||
|
},
|
||||||
|
onRegisterError: (error) => {
|
||||||
|
console.error('Service Worker registration error', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function shouldSkipAutoReload() {
|
||||||
|
const currentPath = window.location.pathname
|
||||||
|
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
|
||||||
|
|
||||||
|
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAndApplyPendingUpdate() {
|
||||||
|
if (hasPendingUpdate && !shouldSkipAutoReload()) {
|
||||||
|
hasPendingUpdate = false
|
||||||
|
void updateSW(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -40,6 +40,11 @@ export const {
|
|||||||
label: 'New comment/rating',
|
label: 'New comment/rating',
|
||||||
icon: 'ri:chat-4-line',
|
icon: 'ri:chat-4-line',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'SUGGESTION_CREATED',
|
||||||
|
label: 'New suggestion',
|
||||||
|
icon: 'ri:lightbulb-line',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'SUGGESTION_MESSAGE',
|
id: 'SUGGESTION_MESSAGE',
|
||||||
label: 'New message in suggestion',
|
label: 'New message in suggestion',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
|||||||
value: T
|
value: T
|
||||||
slug: string
|
slug: string
|
||||||
label: string
|
label: string
|
||||||
|
labelAlt: string
|
||||||
icon: string
|
icon: string
|
||||||
order: number
|
order: number
|
||||||
default: boolean
|
default: boolean
|
||||||
@@ -33,12 +34,14 @@ export const {
|
|||||||
order: Infinity,
|
order: Infinity,
|
||||||
default: false,
|
default: false,
|
||||||
color: 'zinc',
|
color: 'zinc',
|
||||||
|
labelAlt: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
value: 'CREATE_SERVICE',
|
value: 'CREATE_SERVICE',
|
||||||
slug: 'create',
|
slug: 'create',
|
||||||
label: 'Create',
|
label: 'Create',
|
||||||
|
labelAlt: 'service',
|
||||||
icon: 'ri:add-line',
|
icon: 'ri:add-line',
|
||||||
order: 1,
|
order: 1,
|
||||||
default: true,
|
default: true,
|
||||||
@@ -48,6 +51,7 @@ export const {
|
|||||||
value: 'EDIT_SERVICE',
|
value: 'EDIT_SERVICE',
|
||||||
slug: 'edit',
|
slug: 'edit',
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
labelAlt: 'edit',
|
||||||
icon: 'ri:pencil-line',
|
icon: 'ri:pencil-line',
|
||||||
order: 2,
|
order: 2,
|
||||||
default: false,
|
default: false,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Props = ComponentProps<typeof BaseHead> & {
|
|||||||
children: AstroChildren
|
children: AstroChildren
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
success?: string[]
|
success?: string[]
|
||||||
className?: {
|
classNames?: {
|
||||||
body?: string
|
body?: string
|
||||||
main?: string
|
main?: string
|
||||||
footer?: string
|
footer?: string
|
||||||
@@ -31,14 +31,16 @@ type Props = ComponentProps<typeof BaseHead> & {
|
|||||||
| 'max-w-screen-sm'
|
| 'max-w-screen-sm'
|
||||||
| 'max-w-screen-xl'
|
| 'max-w-screen-xl'
|
||||||
| 'max-w-screen-xs'
|
| 'max-w-screen-xs'
|
||||||
|
isErrorPage?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
errors = [],
|
errors = [],
|
||||||
success = [],
|
success = [],
|
||||||
className,
|
classNames,
|
||||||
widthClassName = 'max-w-screen-2xl',
|
widthClassName = 'max-w-screen-2xl',
|
||||||
showSplashText,
|
showSplashText,
|
||||||
|
isErrorPage,
|
||||||
...baseHeadProps
|
...baseHeadProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
@@ -77,7 +79,10 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<BaseHead {...baseHeadProps} />
|
<BaseHead {...baseHeadProps} />
|
||||||
</head>
|
</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" />}
|
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
|
||||||
<Header
|
<Header
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -116,7 +121,7 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<main
|
<main
|
||||||
class={cn(
|
class={cn(
|
||||||
'container mx-auto mt-4 mb-12 grow px-4',
|
'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 === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
|
||||||
widthClassName
|
widthClassName
|
||||||
)}
|
)}
|
||||||
@@ -124,6 +129,6 @@ const announcement = await Astro.locals.banners.try(
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer class={className?.footer} />
|
<Footer class={classNames?.footer} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,20 +9,30 @@ import type { ComponentProps } from 'astro/types'
|
|||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||||
|
size?: 'md' | 'xs'
|
||||||
}
|
}
|
||||||
|
|
||||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
const { layoutHeader, size = 'xs', ...baseLayoutProps } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
className={{
|
classNames={{
|
||||||
...baseLayoutProps.className,
|
main: cn(
|
||||||
main: cn('xs:items-center-safe flex grow flex-col justify-center-safe', baseLayoutProps.className?.main),
|
'flex grow flex-col justify-center-safe',
|
||||||
|
{
|
||||||
|
'xs:items-center-safe': size === 'xs',
|
||||||
|
'md:items-center-safe': size === 'md',
|
||||||
|
},
|
||||||
|
baseLayoutProps.classNames?.main
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
{...baseLayoutProps}
|
{...baseLayoutProps}
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<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" />
|
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||||
@@ -31,14 +41,24 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
|||||||
<h1
|
<h1
|
||||||
class={cn(
|
class={cn(
|
||||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
'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}
|
{layoutHeader.title}
|
||||||
</h1>
|
</h1>
|
||||||
{
|
{
|
||||||
!!layoutHeader.subtitle && (
|
!!layoutHeader.subtitle && (
|
||||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
<p
|
||||||
|
class={cn('text-day-500 mt-1 mb-6 text-center', {
|
||||||
|
'xs:mb-8': size === 'xs',
|
||||||
|
'md:mb-8': size === 'md',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{layoutHeader.subtitle}
|
||||||
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,233 @@ import { formatDateShort } from './timeAgo'
|
|||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
type NonDbAttribute = Prisma.AttributeGetPayload<{
|
||||||
|
select: {
|
||||||
|
title: true
|
||||||
|
type: true
|
||||||
|
category: true
|
||||||
|
description: true
|
||||||
|
privacyPoints: true
|
||||||
|
trustPoints: true
|
||||||
|
}
|
||||||
|
}> & {
|
||||||
|
slug: string
|
||||||
|
links: {
|
||||||
|
url: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nonDbAttributes: (NonDbAttribute & {
|
||||||
|
customize: (
|
||||||
|
service: Prisma.ServiceGetPayload<{
|
||||||
|
select: {
|
||||||
|
verificationStatus: true
|
||||||
|
serviceVisibility: true
|
||||||
|
isRecentlyListed: true
|
||||||
|
listedAt: true
|
||||||
|
createdAt: true
|
||||||
|
tosReviewAt: true
|
||||||
|
tosReview: true
|
||||||
|
onionUrls: true
|
||||||
|
i2pUrls: true
|
||||||
|
acceptedCurrencies: true
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
})[] = [
|
||||||
|
{
|
||||||
|
slug: 'verification-verified',
|
||||||
|
title: 'Verified',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=verified',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-approved',
|
||||||
|
title: 'Approved',
|
||||||
|
type: 'INFO',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'APPROVED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-community-contributed',
|
||||||
|
title: 'Community contributed',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=community',
|
||||||
|
label: 'With this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Without this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'verification-scam',
|
||||||
|
title: 'Is SCAM',
|
||||||
|
type: 'BAD',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}`,
|
||||||
|
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
||||||
|
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?verification=scam',
|
||||||
|
label: 'With this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/?verification=verified&verification=approved',
|
||||||
|
label: 'Without this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
||||||
|
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'archived',
|
||||||
|
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.serviceVisibility === 'ARCHIVED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'recently-listed',
|
||||||
|
title: 'Recently listed',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: -5,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.isRecentlyListed,
|
||||||
|
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'cannot-analyse-tos',
|
||||||
|
title: "Can't analyse ToS",
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description:
|
||||||
|
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: -3,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.tosReviewAt !== null && service.tosReview === null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'has-onion-urls',
|
||||||
|
title: 'Has Onion URLs',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||||
|
privacyPoints: 5,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?onion=true',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.onionUrls.length > 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'has-i2p-urls',
|
||||||
|
title: 'Has I2P URLs',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description: 'I2P URLs enhance privacy and anonymity.',
|
||||||
|
privacyPoints: 5,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?i2p=true',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.i2pUrls.length > 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'monero-accepted',
|
||||||
|
title: 'Accepts Monero',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'PRIVACY',
|
||||||
|
description:
|
||||||
|
'This service accepts Monero, a privacy-focused cryptocurrency that provides enhanced anonymity.',
|
||||||
|
privacyPoints: 5,
|
||||||
|
trustPoints: 0,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
url: '/?currency=monero',
|
||||||
|
label: 'Search with this',
|
||||||
|
icon: 'ri:search-line',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customize: (service) => ({
|
||||||
|
show: service.acceptedCurrencies.includes('MONERO'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function sortAttributes<
|
export function sortAttributes<
|
||||||
T extends Prisma.AttributeGetPayload<{
|
T extends Prisma.AttributeGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
@@ -43,135 +270,19 @@ export function makeNonDbAttributes(
|
|||||||
createdAt: true
|
createdAt: true
|
||||||
tosReviewAt: true
|
tosReviewAt: true
|
||||||
tosReview: true
|
tosReview: true
|
||||||
|
onionUrls: true
|
||||||
|
i2pUrls: true
|
||||||
|
acceptedCurrencies: true
|
||||||
}
|
}
|
||||||
}>,
|
}>,
|
||||||
{ filter = false }: { filter?: boolean } = {}
|
{ filter = false }: { filter?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const nonDbAttributes: (Prisma.AttributeGetPayload<{
|
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
|
||||||
select: {
|
...attribute,
|
||||||
title: true
|
...customize(service),
|
||||||
type: true
|
}))
|
||||||
category: true
|
|
||||||
description: true
|
|
||||||
privacyPoints: true
|
|
||||||
trustPoints: true
|
|
||||||
}
|
|
||||||
}> & {
|
|
||||||
show: boolean
|
|
||||||
links: {
|
|
||||||
url: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
}[]
|
|
||||||
})[] = [
|
|
||||||
{
|
|
||||||
title: 'Verified',
|
|
||||||
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
|
|
||||||
type: 'GOOD',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
|
||||||
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=verified',
|
|
||||||
label: 'Search with this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Approved',
|
|
||||||
show: service.verificationStatus === 'APPROVED',
|
|
||||||
type: 'INFO',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
|
|
||||||
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Search with this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Community contributed',
|
|
||||||
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
|
|
||||||
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=community',
|
|
||||||
label: 'With this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Without this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Is SCAM',
|
|
||||||
show: service.verificationStatus === 'VERIFICATION_FAILED',
|
|
||||||
type: 'BAD',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
|
|
||||||
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
|
|
||||||
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
url: '/?verification=scam',
|
|
||||||
label: 'With this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: '/?verification=verified&verification=approved',
|
|
||||||
label: 'Without this',
|
|
||||||
icon: 'ri:search-line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
|
||||||
show: service.serviceVisibility === 'ARCHIVED',
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
|
||||||
privacyPoints: 0,
|
|
||||||
trustPoints: 0,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Recently listed',
|
|
||||||
show: service.isRecentlyListed,
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
|
||||||
privacyPoints: 0,
|
|
||||||
trustPoints: -5,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Can't analyse ToS",
|
|
||||||
show: service.tosReviewAt !== null && service.tosReview === null,
|
|
||||||
type: 'WARNING',
|
|
||||||
category: 'TRUST',
|
|
||||||
description:
|
|
||||||
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
|
|
||||||
privacyPoints: 0,
|
|
||||||
trustPoints: -3,
|
|
||||||
links: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (filter) return nonDbAttributes.filter(({ show }) => show)
|
if (filter) return attributes.filter(({ show }) => show)
|
||||||
|
|
||||||
return nonDbAttributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|||||||
323
web/src/lib/feeds.ts
Normal file
323
web/src/lib/feeds.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
type SafeResult<T> =
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
error: { message: string; responseInit: ResponseInit }
|
||||||
|
data?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
error?: undefined
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const takeCounts = {
|
||||||
|
serviceComments: 100,
|
||||||
|
serviceEvents: 100,
|
||||||
|
allEvents: 100,
|
||||||
|
userNotifications: 50,
|
||||||
|
} as const satisfies Record<string, number>
|
||||||
|
|
||||||
|
const serviceSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
|
export async function getService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!slug || typeof slug !== 'string') {
|
||||||
|
return { success: false, error: { message: 'Invalid slug', responseInit: { status: 400 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const service =
|
||||||
|
(await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: serviceSelect,
|
||||||
|
})) ??
|
||||||
|
(await prisma.service.findFirst({
|
||||||
|
where: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
previousSlugs: { has: slug },
|
||||||
|
},
|
||||||
|
select: serviceSelect,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
return { success: false, error: { message: 'Service not found', responseInit: { status: 404 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: { service } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceCommentSelect = {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
rating: true,
|
||||||
|
ratingActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
verified: true,
|
||||||
|
admin: true,
|
||||||
|
moderator: true,
|
||||||
|
spammer: true,
|
||||||
|
serviceAffiliations: {
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
service: { select: { name: true, slug: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.CommentSelect
|
||||||
|
|
||||||
|
export async function getCommentsForService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
comments: Prisma.CommentGetPayload<{ select: typeof serviceCommentSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await getService(slug)
|
||||||
|
if (!result.success) return result
|
||||||
|
const { service } = result.data
|
||||||
|
|
||||||
|
const comments = await prisma.comment.findMany({
|
||||||
|
where: {
|
||||||
|
serviceId: service.id,
|
||||||
|
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||||
|
suspicious: false,
|
||||||
|
parentId: null, // Only root comments for the main feed
|
||||||
|
},
|
||||||
|
select: serviceCommentSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.serviceComments,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { service, comments } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
source: true,
|
||||||
|
createdAt: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.EventSelect
|
||||||
|
|
||||||
|
const serviceEventSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
source: true,
|
||||||
|
createdAt: true,
|
||||||
|
} as const satisfies Prisma.EventSelect
|
||||||
|
|
||||||
|
export async function getEventsForService(slug: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
|
||||||
|
events: Prisma.EventGetPayload<{ select: typeof serviceEventSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const result = await getService(slug)
|
||||||
|
if (!result.success) return result
|
||||||
|
const { service } = result.data
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
serviceId: service.id,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
select: serviceEventSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.serviceEvents,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { service, events } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvents(): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
events: Prisma.EventGetPayload<{ select: typeof eventSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
visible: true,
|
||||||
|
service: {
|
||||||
|
listedAt: { lte: new Date() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: eventSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.allEvents,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { events } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
} as const satisfies Prisma.UserSelect
|
||||||
|
|
||||||
|
const notificationSelect = {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
createdAt: true,
|
||||||
|
aboutAccountStatusChange: true,
|
||||||
|
aboutCommentStatusChange: true,
|
||||||
|
aboutServiceVerificationStatusChange: true,
|
||||||
|
aboutSuggestionStatusChange: true,
|
||||||
|
aboutServiceSuggestionId: true,
|
||||||
|
aboutComment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
communityNote: true,
|
||||||
|
status: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutEvent: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
type: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutService: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
verificationStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestion: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutServiceSuggestionMessage: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
suggestion: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
service: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutKarmaTransaction: {
|
||||||
|
select: {
|
||||||
|
points: true,
|
||||||
|
action: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Prisma.NotificationSelect
|
||||||
|
|
||||||
|
export async function getUserNotifications(feedId: string | undefined): Promise<
|
||||||
|
SafeResult<{
|
||||||
|
user: Prisma.UserGetPayload<{ select: typeof userSelect }>
|
||||||
|
notifications: Prisma.NotificationGetPayload<{ select: typeof notificationSelect }>[]
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!feedId || typeof feedId !== 'string') {
|
||||||
|
return { success: false, error: { message: 'Invalid feed ID', responseInit: { status: 400 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { feedId, spammer: false },
|
||||||
|
select: userSelect,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: { message: 'User not found', responseInit: { status: 404 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: notificationSelect,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: takeCounts.userNotifications,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: { user, notifications } }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { commentStatusChangesById } from '../constants/commentStatusChange'
|
|||||||
import { eventTypesById } from '../constants/eventTypes'
|
import { eventTypesById } from '../constants/eventTypes'
|
||||||
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
|
||||||
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
|
||||||
|
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
|
||||||
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
|
||||||
|
|
||||||
import { makeCommentUrl } from './commentsWithReplies'
|
import { makeCommentUrl } from './commentsWithReplies'
|
||||||
@@ -48,6 +49,7 @@ export function makeNotificationTitle(
|
|||||||
aboutServiceSuggestion: {
|
aboutServiceSuggestion: {
|
||||||
select: {
|
select: {
|
||||||
status: true
|
status: true
|
||||||
|
type: true
|
||||||
service: {
|
service: {
|
||||||
select: {
|
select: {
|
||||||
name: true
|
name: true
|
||||||
@@ -131,6 +133,12 @@ export function makeNotificationTitle(
|
|||||||
? `New unmoderated comment on ${service}`
|
? `New unmoderated comment on ${service}`
|
||||||
: `New 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': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
|
||||||
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
|
||||||
@@ -219,6 +227,7 @@ export function makeNotificationContent(
|
|||||||
if (!notification.aboutKarmaTransaction) return null
|
if (!notification.aboutKarmaTransaction) return null
|
||||||
return notification.aboutKarmaTransaction.description
|
return notification.aboutKarmaTransaction.description
|
||||||
}
|
}
|
||||||
|
case 'SUGGESTION_CREATED':
|
||||||
case 'SUGGESTION_STATUS_CHANGE':
|
case 'SUGGESTION_STATUS_CHANGE':
|
||||||
case 'ACCOUNT_STATUS_CHANGE':
|
case 'ACCOUNT_STATUS_CHANGE':
|
||||||
case 'SERVICE_VERIFICATION_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': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return []
|
if (!notification.aboutServiceSuggestionMessage) return []
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export async function sendNotification(
|
|||||||
aboutServiceSuggestion: {
|
aboutServiceSuggestion: {
|
||||||
select: {
|
select: {
|
||||||
status: true,
|
status: true,
|
||||||
|
type: true,
|
||||||
service: {
|
service: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type ServerEventsEvent = {
|
|||||||
}[keyof ServerEventsData]
|
}[keyof ServerEventsData]
|
||||||
|
|
||||||
export type SSEEventMap = {
|
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 {
|
declare global {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
|
|||||||
|
|
||||||
export const zodUrlOptionalProtocol = z.preprocess(
|
export const zodUrlOptionalProtocol = z.preprocess(
|
||||||
cleanUrl,
|
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',
|
message: 'Invalid URL',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
|
|||||||
.trim()
|
.trim()
|
||||||
.refine(
|
.refine(
|
||||||
(value) =>
|
(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
|
value
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
|||||||
<BaseLayout
|
<BaseLayout
|
||||||
pageTitle="404: Page Not Found"
|
pageTitle="404: Page Not Found"
|
||||||
description="The page doesn't exist, double check the URL."
|
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]',
|
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',
|
footer: 'bg-black',
|
||||||
}}
|
}}
|
||||||
widthClassName="max-w-none"
|
widthClassName="max-w-none"
|
||||||
|
isErrorPage
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
data-text="404"
|
data-text="404"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { LOGS_UI_URL } from 'astro:env/server'
|
||||||
|
|
||||||
import { SUPPORT_EMAIL } from '../constants/project'
|
import { SUPPORT_EMAIL } from '../constants/project'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
@@ -21,9 +22,10 @@ const {
|
|||||||
<BaseLayout
|
<BaseLayout
|
||||||
pageTitle="500: Server Error"
|
pageTitle="500: Server Error"
|
||||||
description="Sorry, something crashed on the server."
|
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',
|
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
|
<Icon
|
||||||
name="ri:bug-line"
|
name="ri:bug-line"
|
||||||
@@ -93,6 +95,20 @@ const {
|
|||||||
/>
|
/>
|
||||||
Contact support
|
Contact support
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
|
|||||||
<BaseLayout
|
<BaseLayout
|
||||||
pageTitle="403: Access Denied"
|
pageTitle="403: Access Denied"
|
||||||
description="You don't have permission to access this page."
|
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',
|
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',
|
body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
icon: 'ri:user-3-line',
|
icon: 'ri:user-3-line',
|
||||||
}}
|
}}
|
||||||
widthClassName="max-w-screen-md"
|
widthClassName="max-w-screen-md"
|
||||||
className={{
|
classNames={{
|
||||||
main: 'space-y-6',
|
main: 'space-y-6',
|
||||||
}}
|
}}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</div>
|
</div>
|
||||||
</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]">
|
<div class="min-w-[750px]">
|
||||||
<table class="w-full divide-y divide-zinc-700">
|
<table class="w-full divide-y divide-zinc-700">
|
||||||
<thead class="bg-zinc-900/30">
|
<thead class="bg-zinc-900/30">
|
||||||
@@ -721,51 +721,3 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes highlight {
|
|
||||||
0% {
|
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-color: rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base CSS text size utility */
|
|
||||||
.text-2xs {
|
|
||||||
font-size: 0.6875rem; /* 11px */
|
|
||||||
line-height: 1rem; /* 16px */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar styling for better mobile experience */
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
|
||||||
background: rgba(30, 41, 59, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(75, 85, 99, 0.5);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(100, 116, 139, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.scrollbar-thin {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
|
|
||||||
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ const releaseDate =
|
|||||||
title: 'Releases',
|
title: 'Releases',
|
||||||
subtitle: 'Current release',
|
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">
|
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
|
||||||
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
|
||||||
@@ -36,7 +33,7 @@ const releaseDate =
|
|||||||
</time>
|
</time>
|
||||||
{
|
{
|
||||||
!!releaseDate && (
|
!!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>)
|
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
/>
|
/>
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="ToS URLs"
|
label="ToS URLs"
|
||||||
description="One per line"
|
description="One per line. AI review uses the first working URL only."
|
||||||
name="tosUrls"
|
name="tosUrls"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<BaseLayout
|
<BaseLayout
|
||||||
pageTitle={`${user.displayName ?? user.name} - User`}
|
pageTitle={`${user.displayName ?? user.name} - User`}
|
||||||
widthClassName="max-w-screen-lg"
|
widthClassName="max-w-screen-lg"
|
||||||
className={{ main: 'space-y-24' }}
|
classNames={{ main: 'space-y-24' }}
|
||||||
>
|
>
|
||||||
<div class="mt-12">
|
<div class="mt-12">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
|||||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||||
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
import { getVerificationStatusInfo } from '../constants/verificationStatus'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { sortAttributes } from '../lib/attributes'
|
import { nonDbAttributes, sortAttributes } from '../lib/attributes'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { formatNumber } from '../lib/numbers'
|
import { formatNumber } from '../lib/numbers'
|
||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
@@ -59,9 +59,14 @@ const attributes = await Astro.locals.banners.try(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sortBy = filters['sort-by']
|
const sortBy = filters['sort-by']
|
||||||
|
const mergedAttributes = [
|
||||||
|
...nonDbAttributes.map((attribute) => ({ ...attribute, services: [], id: attribute.slug })),
|
||||||
|
...attributes,
|
||||||
|
]
|
||||||
|
|
||||||
const sortedAttributes = sortBy
|
const sortedAttributes = sortBy
|
||||||
? orderBy(
|
? orderBy(
|
||||||
sortAttributes(attributes),
|
sortAttributes(mergedAttributes),
|
||||||
sortBy === 'type'
|
sortBy === 'type'
|
||||||
? (attribute) => getAttributeTypeInfo(attribute.type).order
|
? (attribute) => getAttributeTypeInfo(attribute.type).order
|
||||||
: sortBy === 'category'
|
: sortBy === 'category'
|
||||||
@@ -73,7 +78,7 @@ const sortedAttributes = sortBy
|
|||||||
: 'trustPoints',
|
: 'trustPoints',
|
||||||
filters['sort-order']
|
filters['sort-order']
|
||||||
)
|
)
|
||||||
: sortAttributes(attributes)
|
: sortAttributes(mergedAttributes)
|
||||||
|
|
||||||
const attributesWithInfo = sortedAttributes.map((attribute) => ({
|
const attributesWithInfo = sortedAttributes.map((attribute) => ({
|
||||||
...attribute,
|
...attribute,
|
||||||
@@ -292,7 +297,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
|||||||
|
|
||||||
<label
|
<label
|
||||||
for={`show-services-${attribute.id}`}
|
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}`}
|
aria-label={`Show services for ${attribute.title}`}
|
||||||
>
|
>
|
||||||
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center justify-center">
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
|||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
listedAt: { lte: new Date() },
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
events: {
|
|
||||||
some: {
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -162,7 +157,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
|
|||||||
pageTitle="Events"
|
pageTitle="Events"
|
||||||
description="Discover important events, updates, and news about KYC-free services in chronological order."
|
description="Discover important events, updates, and news about KYC-free services in chronological order."
|
||||||
widthClassName="max-w-screen-lg"
|
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={{
|
ogImage={{
|
||||||
template: 'generic',
|
template: 'generic',
|
||||||
title: 'Events',
|
title: 'Events',
|
||||||
|
|||||||
41
web/src/pages/feeds/events.xml.ts
Normal file
41
web/src/pages/feeds/events.xml.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import rss from '@astrojs/rss'
|
||||||
|
import { SITE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||||
|
import { getEvents } from '../../lib/feeds'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (context) => {
|
||||||
|
try {
|
||||||
|
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||||
|
|
||||||
|
const result = await getEvents()
|
||||||
|
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||||
|
const { events } = result.data
|
||||||
|
|
||||||
|
return await rss({
|
||||||
|
title: 'KYCnot.me - Service Events',
|
||||||
|
description: 'Latest events and updates from privacy-focused services tracked on KYCnot.me',
|
||||||
|
site: origin,
|
||||||
|
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||||
|
items: events.map((event) => {
|
||||||
|
const eventTypeInfo = getEventTypeInfo(event.type)
|
||||||
|
const isOngoing = !event.endedAt || event.endedAt > new Date()
|
||||||
|
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${event.service.name}: ${event.title}`,
|
||||||
|
pubDate: event.createdAt,
|
||||||
|
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
|
||||||
|
link: `/service/${event.service.slug}/#event-${String(event.id)}`,
|
||||||
|
categories: [eventTypeInfo.label, event.service.name, statusText],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating events RSS feed:', error)
|
||||||
|
return new Response('Error generating RSS feed', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
87
web/src/pages/feeds/index.astro
Normal file
87
web/src/pages/feeds/index.astro
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components'
|
||||||
|
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { takeCounts } from '../../lib/feeds'
|
||||||
|
|
||||||
|
const user = Astro.locals.user
|
||||||
|
|
||||||
|
const feeds = [
|
||||||
|
{
|
||||||
|
title: user ? 'Your notifications' : 'User notifications',
|
||||||
|
description: `Last ${takeCounts.userNotifications.toLocaleString()} of your notifications`,
|
||||||
|
rss: user ? `/feeds/user/${user.feedId}/notifications.xml` : '/feeds/user/[feedId]/notifications.xml',
|
||||||
|
icon: 'ri:notification-line',
|
||||||
|
replacementDescription: user
|
||||||
|
? null
|
||||||
|
: "Replace [feedId] with the user feed ID, found in the user's notifications page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Service comments',
|
||||||
|
description: `Last ${takeCounts.serviceComments.toLocaleString()} comments (and reviews) from a specific service`,
|
||||||
|
rss: '/feeds/service/[slug]/comments.xml',
|
||||||
|
icon: 'ri:chat-3-line',
|
||||||
|
replacementDescription: 'Replace [slug] with the actual service slug',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Service events',
|
||||||
|
description: `Last ${takeCounts.serviceEvents.toLocaleString()} events from a specific service`,
|
||||||
|
rss: '/feeds/service/[slug]/events.xml',
|
||||||
|
icon: 'ri:calendar-event-line',
|
||||||
|
replacementDescription: 'Replace [slug] with the actual service slug',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'All events',
|
||||||
|
description: `Last ${takeCounts.allEvents.toLocaleString()} events from all listed services`,
|
||||||
|
rss: '/feeds/events.xml',
|
||||||
|
icon: 'ri:calendar-2-line',
|
||||||
|
replacementDescription: null,
|
||||||
|
},
|
||||||
|
] as const satisfies {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
rss: `/feeds/${string}`
|
||||||
|
icon: string
|
||||||
|
replacementDescription: string | null
|
||||||
|
}[]
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="RSS Feeds"
|
||||||
|
description="Subscribe to RSS feeds to stay updated with the latest comments and events on KYCnot.me"
|
||||||
|
ogImage={{
|
||||||
|
template: 'generic',
|
||||||
|
title: 'RSS Feeds',
|
||||||
|
description: 'Subscribe to stay updated',
|
||||||
|
icon: 'ri:rss-line',
|
||||||
|
}}
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:rss-line',
|
||||||
|
title: 'RSS Feeds',
|
||||||
|
subtitle: 'Copy the feed URL to your RSS reader',
|
||||||
|
}}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div class="space-y-8">
|
||||||
|
{
|
||||||
|
feeds.map((feed) => (
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-row items-center justify-center gap-2">
|
||||||
|
<Icon name={feed.icon} class="inline-block size-6 shrink-0 text-white" />
|
||||||
|
<h2 class="text-left text-lg leading-tight font-bold text-white">{feed.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-day-300 mt-1 text-center text-sm text-balance">{feed.description}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-night-500 bg-night-600 relative mt-2 rounded-lg border px-4 py-2 font-mono break-all text-white"
|
||||||
|
set:text={`${Astro.url.origin}${feed.rss}`}
|
||||||
|
/>
|
||||||
|
{!!feed.replacementDescription && (
|
||||||
|
<p class="text-day-500 mt-1 text-center text-xs italic">{feed.replacementDescription}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MiniLayout>
|
||||||
61
web/src/pages/feeds/service/[slug]/comments.xml.ts
Normal file
61
web/src/pages/feeds/service/[slug]/comments.xml.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import rss from '@astrojs/rss'
|
||||||
|
import { SITE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
import { getServiceUserRoleInfo } from '../../../../constants/serviceUserRoles'
|
||||||
|
import { makeCommentUrl } from '../../../../lib/commentsWithReplies'
|
||||||
|
import { getCommentsForService } from '../../../../lib/feeds'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (context) => {
|
||||||
|
try {
|
||||||
|
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||||
|
|
||||||
|
const result = await getCommentsForService(context.params.slug)
|
||||||
|
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||||
|
const { service, comments } = result.data
|
||||||
|
|
||||||
|
return await rss({
|
||||||
|
title: `${service.name} - Comments & Reviews | KYCnot.me`,
|
||||||
|
description: `Latest comments and reviews about ${service.name} from KYCnot.me users`,
|
||||||
|
site: origin,
|
||||||
|
xmlns: { dc: 'http://purl.org/dc/elements/1.1/', atom: 'http://www.w3.org/2005/Atom' },
|
||||||
|
items: comments.map((comment) => {
|
||||||
|
const authorName = comment.author.displayName ?? comment.author.name
|
||||||
|
const isRating = comment.ratingActive && comment.rating
|
||||||
|
const title = isRating
|
||||||
|
? `${authorName} rated ${service.name} (${String(comment.rating)}/5 stars)`
|
||||||
|
: `${authorName} commented on ${service.name}`
|
||||||
|
|
||||||
|
const badges = [
|
||||||
|
comment.author.verified ? '✅' : null,
|
||||||
|
comment.author.spammer ? '(Spammer)' : null,
|
||||||
|
comment.author.admin ? '(Admin)' : null,
|
||||||
|
comment.author.moderator && !comment.author.admin ? '(Moderator)' : null,
|
||||||
|
...comment.author.serviceAffiliations.map(
|
||||||
|
(affiliation) =>
|
||||||
|
` (${getServiceUserRoleInfo(affiliation.role).label} at ${affiliation.service.name})`
|
||||||
|
),
|
||||||
|
].filter((badge) => badge !== null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
pubDate: comment.createdAt,
|
||||||
|
description: comment.content,
|
||||||
|
link: makeCommentUrl({
|
||||||
|
origin,
|
||||||
|
serviceSlug: service.slug,
|
||||||
|
commentId: comment.id,
|
||||||
|
}),
|
||||||
|
categories: isRating ? ['Rating'] : ['Comment'],
|
||||||
|
guid: `${service.slug}-comment-${String(comment.id)}`,
|
||||||
|
customData: `<dc:creator>${authorName}${badges.length > 0 ? ` ${badges.join(' ')}` : ''}</dc:creator>`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating service comments RSS feed:', error)
|
||||||
|
return new Response('Error generating RSS feed', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
41
web/src/pages/feeds/service/[slug]/events.xml.ts
Normal file
41
web/src/pages/feeds/service/[slug]/events.xml.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import rss from '@astrojs/rss'
|
||||||
|
import { SITE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
import { getEventTypeInfo } from '../../../../constants/eventTypes'
|
||||||
|
import { getEventsForService } from '../../../../lib/feeds'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (context) => {
|
||||||
|
try {
|
||||||
|
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||||
|
|
||||||
|
const result = await getEventsForService(context.params.slug)
|
||||||
|
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||||
|
const { service, events } = result.data
|
||||||
|
|
||||||
|
return await rss({
|
||||||
|
title: `${service.name} - Events & Updates | KYCnot.me`,
|
||||||
|
description: `Latest events and updates for ${service.name} tracked on KYCnot.me`,
|
||||||
|
site: origin,
|
||||||
|
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||||
|
items: events.map((event) => {
|
||||||
|
const eventTypeInfo = getEventTypeInfo(event.type)
|
||||||
|
const isOngoing = !event.endedAt || event.endedAt > new Date()
|
||||||
|
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${service.name}: ${event.title}`,
|
||||||
|
pubDate: event.createdAt,
|
||||||
|
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
|
||||||
|
link: `/service/${service.slug}/#event-${String(event.id)}`,
|
||||||
|
categories: [eventTypeInfo.label, statusText],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating service events RSS feed:', error)
|
||||||
|
return new Response('Error generating RSS feed', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
52
web/src/pages/feeds/user/[feedId]/notifications.xml.ts
Normal file
52
web/src/pages/feeds/user/[feedId]/notifications.xml.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import rss from '@astrojs/rss'
|
||||||
|
import { SITE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
import { getNotificationTypeInfo } from '../../../../constants/notificationTypes'
|
||||||
|
import { getUserNotifications } from '../../../../lib/feeds'
|
||||||
|
import {
|
||||||
|
makeNotificationActions,
|
||||||
|
makeNotificationContent,
|
||||||
|
makeNotificationTitle,
|
||||||
|
} from '../../../../lib/notifications'
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
|
export const GET: APIRoute = async (context) => {
|
||||||
|
try {
|
||||||
|
const origin = context.site?.origin ?? new URL(SITE_URL).origin
|
||||||
|
const feedId = context.params.feedId
|
||||||
|
|
||||||
|
const result = await getUserNotifications(feedId)
|
||||||
|
if (!result.success) return new Response(result.error.message, result.error.responseInit)
|
||||||
|
const { user, notifications } = result.data
|
||||||
|
|
||||||
|
const displayName = user.displayName ?? user.name
|
||||||
|
|
||||||
|
return await rss({
|
||||||
|
title: `${displayName}'s Notifications - KYCnot.me`,
|
||||||
|
description: `Notifications for ${displayName} on KYCnot.me - Privacy-focused service reviews and updates`,
|
||||||
|
site: origin,
|
||||||
|
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
|
||||||
|
items: notifications.map((notification) => {
|
||||||
|
const typeInfo = getNotificationTypeInfo(notification.type)
|
||||||
|
|
||||||
|
const title = makeNotificationTitle(notification, user)
|
||||||
|
const description = makeNotificationContent(notification) ?? typeInfo.label
|
||||||
|
const actions = makeNotificationActions(notification, origin)
|
||||||
|
const link = actions[0]?.url ?? `${origin}/notifications`
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
pubDate: notification.createdAt,
|
||||||
|
description,
|
||||||
|
link,
|
||||||
|
categories: [typeInfo.label],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating user notifications RSS feed:', error)
|
||||||
|
return new Response('Error generating RSS feed', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
import Button from '../components/Button.astro'
|
import Button from '../components/Button.astro'
|
||||||
|
import CopyButton from '../components/CopyButton.astro'
|
||||||
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
||||||
import TimeFormatted from '../components/TimeFormatted.astro'
|
import TimeFormatted from '../components/TimeFormatted.astro'
|
||||||
import Tooltip from '../components/Tooltip.astro'
|
import Tooltip from '../components/Tooltip.astro'
|
||||||
@@ -84,6 +85,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
|
|||||||
aboutServiceSuggestion: {
|
aboutServiceSuggestion: {
|
||||||
select: {
|
select: {
|
||||||
status: true,
|
status: true,
|
||||||
|
type: true,
|
||||||
service: {
|
service: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -256,7 +258,7 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
label="Reload"
|
label="Reload"
|
||||||
icon="ri:refresh-line"
|
icon="ri:refresh-line"
|
||||||
color="white"
|
color="white"
|
||||||
class="ml-auto"
|
class="no-js:hidden ml-auto"
|
||||||
onclick="window.location.reload()"
|
onclick="window.location.reload()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,11 +406,73 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('sse-new-notification', () => {
|
document.addEventListener('sse:new-notification', () => {
|
||||||
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
|
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
|
||||||
banner.style.display = ''
|
banner.style.display = ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { actions } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '../../actions/serviceSuggestion'
|
} from '../../actions/serviceSuggestion'
|
||||||
import Captcha from '../../components/Captcha.astro'
|
import Captcha from '../../components/Captcha.astro'
|
||||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||||
|
import InputCheckbox from '../../components/InputCheckbox.astro'
|
||||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||||
import InputImageFile from '../../components/InputImageFile.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) {
|
if (result && !result.error && !result.data.hasDuplicates) {
|
||||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
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([
|
const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||||
[
|
[
|
||||||
@@ -239,7 +240,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
|
|
||||||
<InputTextArea
|
<InputTextArea
|
||||||
label="ToS URLs"
|
label="ToS URLs"
|
||||||
description="One per line"
|
description="One per line. AI review uses the first working URL only."
|
||||||
name="tosUrls"
|
name="tosUrls"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
placeholder: 'example.com/tos',
|
placeholder: 'example.com/tos',
|
||||||
@@ -349,16 +350,10 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
|
|
||||||
<Captcha action={actions.serviceSuggestion.createService} />
|
<Captcha action={actions.serviceSuggestion.createService} />
|
||||||
|
|
||||||
<div>
|
<InputCheckbox name="rulesConfirm" required error={inputErrors.rulesConfirm}>
|
||||||
<div class="flex items-center gap-2 text-lg">
|
I understand the
|
||||||
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required />
|
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
|
||||||
<label for="rules-confirm">
|
</InputCheckbox>
|
||||||
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>
|
|
||||||
|
|
||||||
<InputHoneypotTrap name="message" />
|
<InputHoneypotTrap name="message" />
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
icon: 'ri:user-3-line',
|
icon: 'ri:user-3-line',
|
||||||
}}
|
}}
|
||||||
widthClassName="max-w-screen-md"
|
widthClassName="max-w-screen-md"
|
||||||
className={{
|
classNames={{
|
||||||
main: 'space-y-6',
|
main: 'space-y-6',
|
||||||
}}
|
}}
|
||||||
htmx
|
htmx
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { registerSW } from 'virtual:pwa-register'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
||||||
interface Window {
|
|
||||||
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateSW = registerSW({
|
|
||||||
immediate: true,
|
|
||||||
onRegisteredSW: (_swScriptUrl, registration) => {
|
|
||||||
if (registration) window.__SW_REGISTRATION__ = registration
|
|
||||||
},
|
|
||||||
onNeedRefresh: () => {
|
|
||||||
void updateSW(true)
|
|
||||||
},
|
|
||||||
onRegisterError: (error) => {
|
|
||||||
console.error('Service Worker registration error', error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user