Compare commits

...

3 Commits

Author SHA1 Message Date
pluja
39afcad089 Release 202506111705 2025-06-11 17:05:58 +00:00
pluja
99cb730bc0 Release 202506111039 2025-06-11 10:39:20 +00:00
pluja
d43402e162 Release 202506111007 2025-06-11 10:07:51 +00:00
17 changed files with 167 additions and 101 deletions

View File

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

View File

@@ -107,6 +107,7 @@ export default defineConfig({
'/service/[...slug]/proof': '/service/[...slug]/#verification', '/service/[...slug]/proof': '/service/[...slug]/#verification',
'/attribute/[...slug]': '/attributes', '/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes', '/attr/[...slug]': '/attributes',
'/service/[...slug]/review': '/service/[...slug]#comments',
// #endregion // #endregion
}, },
env: { env: {

View File

@@ -1143,7 +1143,7 @@ async function main() {
} }
let users = await Promise.all( let users = await Promise.all(
Array.from({ length: 10 }, async () => { Array.from({ length: 570 }, async () => {
const { user } = await createAccount() const { user } = await createAccount()
return user return user
}) })

View File

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

View File

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

View File

@@ -5,13 +5,6 @@
<script> <script>
import { registerSW } from 'virtual:pwa-register' 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}`[] const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
let hasPendingUpdate = false let hasPendingUpdate = false

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -2,6 +2,8 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getKycLevelClarificationInfo } from '../constants/kycLevelClarifications'
import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility' import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
@@ -27,7 +29,7 @@ type NonDbAttribute = Prisma.AttributeGetPayload<{
}[] }[]
} }
export const nonDbAttributes: (NonDbAttribute & { type NonDbAttributeFull = NonDbAttribute & {
customize: ( customize: (
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
select: { select: {
@@ -41,12 +43,16 @@ export const nonDbAttributes: (NonDbAttribute & {
onionUrls: true onionUrls: true
i2pUrls: true i2pUrls: true
acceptedCurrencies: true acceptedCurrencies: true
kycLevel: true
kycLevelClarification: true
} }
}> }>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & { ) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
show: boolean show: boolean
} }
})[] = [ }
export const nonDbAttributes: NonDbAttributeFull[] = [
{ {
slug: 'verification-verified', slug: 'verification-verified',
title: 'Verified', title: 'Verified',
@@ -135,6 +141,31 @@ export const nonDbAttributes: (NonDbAttribute & {
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`, description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
}), }),
}, },
...kycLevels.map<NonDbAttributeFull>((kycLevel) => ({
slug: `kyc-level-${kycLevel.id}`,
title: kycLevel.name,
type: kycLevel.attributeType,
category: 'PRIVACY',
description: kycLevel.description,
privacyPoints: kycLevel.privacyPoints,
trustPoints: 0,
links: [
{
url: `/?max-kyc=${kycLevel.id}`,
label: 'With this or better',
icon: 'ri:search-line',
},
],
customize: (service) => {
const clarification = getKycLevelClarificationInfo(service.kycLevelClarification)
return {
show: service.kycLevel === kycLevel.value,
title: kycLevel.name + (clarification.value !== 'NONE' ? ` (${clarification.label})` : ''),
description:
kycLevel.description + (clarification.value !== 'NONE' ? ` ${clarification.description}` : ''),
}
},
})),
{ {
slug: 'archived', slug: 'archived',
title: serviceVisibilitiesById.ARCHIVED.label, title: serviceVisibilitiesById.ARCHIVED.label,
@@ -261,20 +292,7 @@ export function sortAttributes<
} }
export function makeNonDbAttributes( export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{ service: Parameters<NonDbAttributeFull['customize']>[0],
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
createdAt: true
tosReviewAt: true
tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
}
}>,
{ filter = false }: { filter?: boolean } = {} { filter = false }: { filter?: boolean } = {}
) { ) {
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({ const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({

View File

@@ -153,15 +153,6 @@ Scores are calculated **automatically** using clear, fixed rules. We do not chan
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach: The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points. 1. **Base Score:** Every service starts with a neutral score of 50 points.
1. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points**
1. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
1. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
1. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes). 1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Final Score Range:** The final score is always kept between 0 and 100.
@@ -170,13 +161,6 @@ The privacy score measures how well a service protects user privacy, using a tra
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria. The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points. 1. **Base Score:** Every service begins with a neutral score of 50 points.
1. **Verification Status:**
- **Verification Success:** +10 points
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
1. **Recently Listed:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
1. **Can't Analyze ToS:** If a service's Terms of Service cannot be analyzed by our AI (usually due to captchas, client-side rendering, DDoS protections, or non-text format), a penalty of -3 points is applied to the trust score.
1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes). 1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Final Score Range:** The final score is always kept between 0 and 100.

View File

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

View File

@@ -801,7 +801,8 @@ const apiCalls = await Astro.locals.banners.try(
label: type.label, label: type.label,
value: type.id, value: type.id,
}))} }))}
selectProps={{ required: true, value: event.type }} selectedValue={event.type}
selectProps={{ required: true }}
error={eventUpdateInputErrors.type} error={eventUpdateInputErrors.type}
/> />
</div> </div>
@@ -982,7 +983,7 @@ const apiCalls = await Astro.locals.banners.try(
label: status.label, label: status.label,
value: status.value, value: status.value,
}))} }))}
selectProps={{ value: step.status }} selectedValue={step.status}
error={verificationStepUpdateInputErrors.status} error={verificationStepUpdateInputErrors.status}
/> />

View File

@@ -27,7 +27,7 @@ import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro' import UserBadge from '../../components/UserBadge.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro' import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { baseScoreType, getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods' import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies' import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes' import { getEventTypeInfo } from '../../constants/eventTypes'
@@ -407,9 +407,12 @@ const ogImageTemplateData = {
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility) const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter( const activeAlertOrWarningEvents = service.events
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now) .map((event) => ({
) ...event,
typeInfo: getEventTypeInfo(event.type),
}))
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
const activeEventToShow = const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0] activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
--- ---
@@ -518,15 +521,10 @@ const activeEventToShow =
href="#events" href="#events"
class={cn( class={cn(
'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200', 'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
activeEventToShow.type === EventType.ALERT activeEventToShow.typeInfo.classNames.banner
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
)} )}
> >
<Icon <Icon name={activeEventToShow.typeInfo.icon} class="me-1.5 inline-block size-4 align-[-0.15em]" />
name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
<span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content} <span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
{activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>} {activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
<span class="underline">Go to events</span> <span class="underline">Go to events</span>
@@ -1052,10 +1050,27 @@ const activeEventToShow =
</li> </li>
) )
})} })}
<li
class={cn(
'bg-night-400 flex items-center self-start rounded-md p-2 text-sm text-pretty select-none',
baseScoreType.classNames.container,
baseScoreType.classNames.text
)}
>
<Icon
name={baseScoreType.icon}
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
/>
<span class="font-title">{baseScoreType.label}</span>
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
</li>
</ul> </ul>
) )
} }
<p class="text-day-400 mt-3 text-center text-xs">
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Rounded)</span>
</p>
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs"> <div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
<a <a
href="/about#service-scores" href="/about#service-scores"
@@ -1069,7 +1084,7 @@ const activeEventToShow =
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline" class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
> >
<Icon name="ri:information-line" class="size-3" /> <Icon name="ri:information-line" class="size-3" />
Attributes list All attributes list
</a> </a>
</div> </div>
@@ -1180,15 +1195,15 @@ const activeEventToShow =
<p class="text-day-500 mt-1 text-sm text-balance"> <p class="text-day-500 mt-1 text-sm text-balance">
Maybe due to captchas, client side rendering, DDoS protections, or non-text format. Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
</p> </p>
{service.tosUrls.length > 0 && ( <p class="mt-2 text-xs">
<p class="mt-2 text-xs"> Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.map((url) => ( {service.tosUrls.length > 0 && 'from'}
<a href={url} class="hover:underline"> {service.tosUrls.map((url) => (
{urlDomain(url)} <a href={url} class="hover:underline">
</a> {urlDomain(url)}
))} </a>
</p> ))}
)} </p>
</div> </div>
) )
) : ( ) : (
@@ -1459,6 +1474,7 @@ const activeEventToShow =
Comments Comments
</h2> </h2>
<div <div
id="discuss"
class='grid grid-cols-1 gap-8 [grid-template-areas:"about""rating""ai"] sm:grid-cols-[1fr_1fr] sm:gap-4 sm:[grid-template-areas:"about_ai""rating_ai"]' class='grid grid-cols-1 gap-8 [grid-template-areas:"about""rating""ai"] sm:grid-cols-[1fr_1fr] sm:gap-4 sm:[grid-template-areas:"about_ai""rating_ai"]'
> >
<div class="relative rounded-md bg-orange-400/10 p-2 px-2.5 text-xs text-orange-100/70 [grid-area:about]"> <div class="relative rounded-md bg-orange-400/10 p-2 px-2.5 text-xs text-orange-100/70 [grid-area:about]">

View File

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