Compare commits
6 Commits
release-66
...
release-72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39afcad089 | ||
|
|
99cb730bc0 | ||
|
|
d43402e162 | ||
|
|
9bb316b85f | ||
|
|
4aea68ee58 | ||
|
|
2f88c43236 |
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ Task for retrieving Terms of Service (TOS) text.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Any, Dict, Optional, Literal
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
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
|
||||||
@@ -53,13 +55,37 @@ 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"))
|
review = self.get_tos_review(tos_urls, service.get("tosReview"))
|
||||||
|
|
||||||
|
# Always update the processed timestamp, even if review is None
|
||||||
save_tos_review(service_id, review)
|
save_tos_review(service_id, review)
|
||||||
|
|
||||||
# Update the KYC level based on the review
|
if review is None:
|
||||||
|
self.logger.warning(
|
||||||
|
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update the KYC level based on the review, when present
|
||||||
if "kycLevel" in review:
|
if "kycLevel" in review:
|
||||||
kyc_level = review["kycLevel"]
|
new_level = review["kycLevel"]
|
||||||
self.logger.info(f"Updating KYC level to {kyc_level} for service {service_name}")
|
old_level = service.get("kycLevel")
|
||||||
update_kyc_level(service_id, kyc_level)
|
|
||||||
|
# Update DB
|
||||||
|
if update_kyc_level(service_id, new_level):
|
||||||
|
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
|
||||||
|
|
||||||
|
# Log to console
|
||||||
|
self.logger.info(msg)
|
||||||
|
|
||||||
|
# Send notification via ntfy
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to send ntfy notification for KYC level change: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return review
|
return review
|
||||||
|
|
||||||
@@ -87,7 +113,9 @@ class TosReviewTask(Task):
|
|||||||
content = fetch_markdown(api_url)
|
content = fetch_markdown(api_url)
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
self.logger.warning(f"Failed to retrieve TOS content for URL: {tos_url}")
|
self.logger.warning(
|
||||||
|
f"Failed to retrieve TOS content for URL: {tos_url}"
|
||||||
|
)
|
||||||
all_skipped = False
|
all_skipped = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -173,12 +173,12 @@ type TosReview = {
|
|||||||
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
||||||
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
||||||
rating: 'negative' | 'neutral' | 'positive'
|
rating: 'negative' | 'neutral' | 'positive'
|
||||||
}[]
|
}[] // max 8 highlights, try to provide at least 3.
|
||||||
}
|
}
|
||||||
|
|
||||||
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
||||||
|
|
||||||
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.
|
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -498,7 +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))
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
1
web/src/env.d.ts
vendored
@@ -17,6 +17,7 @@ declare global {
|
|||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
htmx?: typeof htmx
|
htmx?: typeof htmx
|
||||||
|
__SW_REGISTRATION__?: ServiceWorkerRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace PrismaJson {
|
namespace PrismaJson {
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
|||||||
Reference in New Issue
Block a user