Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:23:36 +00:00
parent 2657f936bc
commit 565e9a0ad1
267 changed files with 49417 additions and 0 deletions

101
web/src/pages/404.astro Normal file
View File

@@ -0,0 +1,101 @@
---
import { Icon } from 'astro-icon/components'
import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
pageTitle="404: Page Not Found"
description="The page doesn't exist, double check the URL."
className={{
main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]',
footer: 'bg-black',
}}
widthClassName="max-w-none"
>
<h1
data-text="404"
data-animation-title
class="m-0 mb-4 text-[clamp(5rem,40vmin,15rem)] font-extrabold tracking-[clamp(0.5rem,2vmin,2rem)]"
>
404
</h1>
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="absolute top-1/2 left-1/2 h-[250vmax] w-[250vmax] -translate-x-1/2 -translate-y-1/2">
<div data-animation-cloak></div>
</div>
</div>
<div class="text-center leading-relaxed">
<h2 class="font-title mb-2 text-3xl tracking-wider text-balance uppercase">Page not found</h2>
<p class="mb-12 text-base text-balance">The page doesn't exist, double check the URL.</p>
<a
href="/"
class="group inline-flex items-center gap-4 rounded-lg border-2 border-zinc-100 bg-zinc-900 px-6 py-2 text-lg tracking-wider text-white uppercase transition-colors hover:bg-zinc-800"
>
<Icon
name="ri:arrow-left-line"
class="-mx-1 h-5 w-5 transition-transform group-hover:-translate-x-1 group-active:translate-x-0"
/>
Home
</a>
</div>
</BaseLayout>
<style>
@property --swing-x {
initial-value: 0;
inherits: false;
syntax: '<integer>';
}
@property --swing-y {
initial-value: 0;
inherits: false;
syntax: '<integer>';
}
[data-animation-title] {
animation: swing var(--speed) infinite alternate ease-in-out;
transform: translate3d(0, 0, 0vmin);
--x: calc(50% + (var(--swing-x) * 0.5) * 1%);
background: radial-gradient(#e6e6e6, #7a7a7a 45%) var(--x) 100%/200% 200%;
-webkit-background-clip: text;
color: transparent;
}
[data-animation-title]:after {
animation: swing var(--speed) infinite alternate ease-in-out;
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
color: #000;
filter: blur(1.5vmin);
transform: scale(1.05) translate3d(0, 12%, -10vmin)
translate(calc((var(--swing-x, 0) * 0.05) * 1%), calc((var(--swing-y) * 0.05) * 1%));
}
[data-animation-cloak] {
animation: swing var(--speed) infinite alternate-reverse ease-in-out;
height: 100%;
width: 100%;
transform-origin: 50% 30%;
transform: rotate(calc(var(--swing-x) * -0.25deg));
background: radial-gradient(40% 40% at 50% 42%, transparent, #000 35%);
}
@keyframes swing {
0% {
--swing-x: -100;
--swing-y: -100;
}
50% {
--swing-y: 0;
}
100% {
--swing-y: -100;
--swing-x: 100;
}
}
</style>

105
web/src/pages/500.astro Normal file
View File

@@ -0,0 +1,105 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
type Props = {
error: unknown
}
const { error } = Astro.props
const {
data: { message },
} = zodParseQueryParamsStoringErrors({ message: z.string().optional() }, Astro)
---
<BaseLayout
pageTitle="500: Server Error"
description="Sorry, something crashed on the server."
className={{
main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
}}
>
<Icon
name="ri:bug-line"
class="text-night-800 absolute top-0 right-8 -z-50 size-32 -rotate-45 sm:top-12 md:right-16"
/>
<p class="text-primary text-day-700 text-9xl font-black">500</p>
<h1 class="font-title mt-8 text-3xl font-bold tracking-tight text-white sm:mt-12 sm:text-5xl">
Server Error
</h1>
<p class="text-day-400 mt-6 text-base leading-7 text-balance">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{message || 'Sorry, something crashed on the server.'}
</p>
{
(DEPLOYMENT_MODE !== 'production' || Astro.locals.user?.admin) && (
<div class="bg-night-800 mt-4 block max-h-96 min-h-32 w-full max-w-4xl overflow-auto rounded-lg p-4 text-left text-sm break-words whitespace-pre-wrap">
{error instanceof Error
? error.message
: error === undefined
? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
message || 'undefined'
: typeof error === 'object'
? JSON.stringify(error, null, 2)
: String(error as unknown)}
</div>
)
}
<div class="mt-10 flex flex-wrap items-center justify-center">
<button
data-reload-button
class="focus-visible:outline-primary group no-js:hidden flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:refresh-line"
class="size-5 transition-transform duration-300 group-hover:rotate-90 group-active:rotate-180"
/>
Try again
</button>
<a
href={Astro.url}
class="focus-visible:outline-primary group js:hidden flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:refresh-line"
class="size-5 transition-transform duration-300 group-hover:rotate-90 group-active:rotate-180"
/>
Try again
</a>
<a href="/" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white">
<Icon
name="ri:arrow-left-line"
class="size-5 transition-transform group-hover:-translate-x-1 group-active:translate-x-0"
/>
Go back home
</a>
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:mail-line"
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
/>
Contact support
</a>
</div>
</BaseLayout>
<script>
document.addEventListener('astro:page-load', () => {
const reloadButtons = document.querySelectorAll<HTMLButtonElement>('[data-reload-button]')
reloadButtons.forEach((button) => {
button.addEventListener('click', () => {
window.location.reload()
})
})
})
</script>

211
web/src/pages/about.md Normal file
View File

@@ -0,0 +1,211 @@
---
layout: ../layouts/MarkdownLayout.astro
title: About
author: KYCnot.me
pubDate: 2025-05-15
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
---
## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
## What is KYC?
**KYC** stands for _Know Your Customer_, a process designed to protect financial institutions against fraud, corruption, money laundering and terrorist financing.
The truth is that KYC is a **direct attack on our privacy** and puts us in disadvantage against the governments. **True criminals don't care about KYC policies**. True criminals know perfectly how to avoid such policies. In fact, they normally use the FIAT system and don't even need to use cryptocurrencies. Banks are the biggest money launders, the [HSBC scandal](https://www.reuters.com/business/hsbc-fined-85-mln-anti-money-laundering-failings-2021-12-17/), [Nordea](https://www.reuters.com/article/us-nordea-bnk-moneylaundering-idUSKCN1QL11S) or [Swedbank](https://www.reuters.com/article/us-europe-moneylaundering-swedbank/swedbank-hit-with-record-386-million-fine-over-baltic-money-laundering-breaches-idUSKBN2163LU) are just some examples.
Chainalysis found that only 0.34% of the transaction volume with cryptocurrencies in 2023 was attributable to criminal activity. Bitcoin's share of this is significantly lower. Illicit transactions with Euros accounted for 1% of the EU's GDP or €110 billion in 2010. [[1]](https://www.chainalysis.com/blog/2024-crypto-crime-report-introduction/) [[2]](https://transparency.eu/priority/financial-flows-crime/)
KYC only affects small individuals like you and me. It is an annoying procedure that **forces us to trust our personal information to a third party** in order to buy, use or unlock our funds. We should start using cryptocurrencies as they were intended to be used: without barriers.
## Why does this site exist?
Crypto was born to free us from banks and governments controlling our money. Simple as that.
When exchanges require your ID and personal information through KYC, they undermine the core principle of cryptocurrency: privacy. Not everyone possesses an ID, and not everyone resides in a "supported" country. Small businesses often struggle with the burden of compliance and the fear of hefty fines. Moreover, exchanges are [targets for hackers](https://www.reuters.com/business/coinbase-says-cyber-criminals-stole-account-data-some-customers-2025-05-15/?ref=guptadeepak.com), putting your sensitive data at risk of theft.
KYC turns crypto back into the system we're trying to escape. That's why I built this site - to help you use crypto the way it was meant to be used: privately.
## Why only Bitcoin and Monero?
**Bitcoin**: It's the initial spark of the decentralized money. A solid project with a strong community. It is the most well-known and widespread cryptocurrency.
**Monero**: If you're looking for digital cash that's truly private, Monero is it. It's designed for privacy, works like real cash (fungible), has low fees, and is supported by a dedicated, long-standing community.
While the main focus is on Bitcoin and Monero, you'll find that many of the listed services also accept other cryptocurrencies, such as Ethereum or Litecoin.
## User Accounts
You can [create an account](/account/generate) to suggest new services or share your feedback on service pages.
User accounts do not require any personal information. Your username will be **randomly** generated to prevent impersonation and protect your privacy.
When you create an account, you are given a **login key**. Login keys are **displayed only once**. Be sure to **store it securely**, as it **cannot be recovered** if lost. It is recommended to use a password manager like [Bitwarden](https://bitwarden.com) or [KeePassXC](https://keepassxc.org/).
### User Karma
Users earn karma by participating in the community. When your comments get approved, or when making contributions. As your karma grows, you'll unlock **special features**, which are detailed on your [user profile page](/account).
### Verified and Affiliated Users
Some users are **verified**, this means that the moderators have confirmed that the user is related to a specific link or service. The verification is directly linked to the URL, ensuring that the person behind the username has a legitimate connection to the service they claim to represent. This verification process is reserved for individuals with established reputation.
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
## Listings
### Suggesting a new listing
To suggest a new listing, visit the [service suggestion form](/service-suggestion/new) and provide the most accurate information possible for higher chances to get approved.
Once submitted, you get a unique tracking page where you can monitor its status and communicate directly with moderators.
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
### Suggestion Review Process
#### First Review
- A member of the **KYCnot.me team** reviews the submission to ensure it isn't spam or inappropriate.
- If the listing passes this initial check, it becomes **publicly visible** to all users.
- At this stage, the listing is **Community Contributed** and will show a disclaimer.
#### Second Review (APPROVED)
- The service is tested and investigated again.
- If it proves to be reliable, it is granted the `APPROVED` status, which means:
- The information is accurate.
- The service works as described (at the time of the testing).
- Basic functionality has been tested.
- Communication with the service's support was successful.
- A brief investigation found no obvious red flags.
#### Final Review (VERIFIED)
- After a period of no reported issues, the service will undergo a **third, comprehensive review**.
- The service is tested across different dates and under various conditions.
- The service administrators and support teams will be contacted for additional verification.
- If the service meets all requirements, it is granted the **`VERIFIED`** status.
#### Failed Verifications
If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer.
### Verification Steps
Services will usually show the verification steps that the admins took to reach the verified (or not) status. Each step will have a description and some evidence attached.
### Service Attributes
An attribute is a feature of a service, categorized as:
- **Good** A positive feature
- **Warning** A potential concern
- **Bad** A significant drawback
- **Information** Neutral details
You can view all available attributes on the [Attributes page](/attributes).
Attributes are classified into two main types:
1. **Privacy Attributes** Related to data protection and anonymity.
2. **Trust Attributes** Related to reliability and security.
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
### Service Scores
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
#### Privacy Score
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points.
2. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points**
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score.
7. **Final Score Range:** The final score is always kept between 0 and 100.
#### Trust Score
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points.
2. **Verification Status Adjustment:**
- **Verification Success:** +10 points
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score.
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
5. **Final Score Range:** The final score is always kept between 0 and 100.
#### Overall Score
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
### Terms of Service Reviews
KYCnot.me automatically reviews and summarizes the Terms of Service (ToS) for every service monthly using AI. You get simple, clear summaries that highlight the most important points, so you can quickly see what matters.
We hash each ToS document and only review it again if it changes. Some services may go a long time without a new review, but we still check and scrape their ToS every month.
We aim for accuracy, but the AI may sometimes miss details or highlight less relevant information. If you see any error, contact us.
### Events
There are two types of events:
- Automated events: Created by the system whenever something about a service changes, like its description, supported currencies, attributes, verification status...
- Manual events: Added by admins when there's important news, such as a service going offline, being hacked, acquired, shut down, or other major updates.
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
### Reviews and Comments
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
You can also post regular comments to share your experience, ask questions, or discuss the service.
If you've used the service, you can add an **order ID** or proof—this is only visible to admins for verification. You can also **flag** comments for issues like blocked funds or KYC requirements.
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
#### Note on moderation
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## Support the project
If you like this project, you can support it through these methods:
- Monero:
- `88V2Xi2mvcu3NdnHkVeZGyPtACg2w3iXZdUMJugUiPvFQHv5mVkih3o43ceVGz6cVs9uTBMt4MRMVW2xFgfGdh8DTCQ7vtp`
## Contact
You can contact via direct chat or via email.
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
- If you use ProtonMail or Tutanota, you can have E2E encrypted communications with us directly. We also offer a [PGP Key](/pgp). Otherwise, we recommend reaching out via SimpleX chat for encrypted communications.
- [tuta.io](https://tuta.io) - <kycnotme@tuta.io>
- [proton.me](https://proton.me) - <contact@kycnot.me>
## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.
By using this website, you acknowledge and agree that you are solely responsible for your actions, due diligence, and compliance with all applicable laws. You use the information and any linked services entirely at your own risk. The operators of this website will not be held liable for any losses, damages, or legal consequences arising from your use of this site or any services listed herein.

View File

@@ -0,0 +1,75 @@
---
import { Icon } from 'astro-icon/components'
import { z } from 'astro:content'
import BaseLayout from '../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
const {
data: { reason, reasonType, redirect },
} = zodParseQueryParamsStoringErrors(
{
reason: z.string().optional(),
reasonType: z.enum(['admin-required']).optional(),
redirect: z.string().optional(),
},
Astro
)
if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
return Astro.redirect(redirect)
}
---
<BaseLayout
pageTitle="403: Access Denied"
description="You don't have permission to access this page."
className={{
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',
}}
>
<Icon name="ri:hand" class="xs:size-24 size-16 text-red-100 sm:size-32" />
<h1 class="font-title mt-8 text-3xl font-semibold tracking-wide text-white sm:mt-12 sm:text-5xl">
Access denied
</h1>
<p class="mt-8 text-lg leading-7 text-balance whitespace-pre-wrap text-red-400">
{reason}
</p>
<div class="mt-12 flex flex-wrap items-center justify-center">
<a href="/" class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white">
<Icon
name="ri:home-2-line"
class="size-5 transition-transform group-hover:-translate-x-1 group-active:translate-x-0"
/>
Go to home
</a>
<a
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
data-astro-reload
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:user-line"
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
/>
Login as different user
</a>
{
Astro.locals.actualUser && (
<a
href={makeUnimpersonateUrl(Astro.url, { redirect })}
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:spy-line"
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
/>
Unimpersonate
</a>
)
}
</div>
</BaseLayout>

View File

@@ -0,0 +1,145 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import Button from '../../components/Button.astro'
import { karmaUnlocksById } from '../../constants/karmaUnlocks'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { makeKarmaUnlockMessage } from '../../lib/karmaUnlocks'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to edit your profile' }))
}
const result = Astro.getActionResult(actions.account.update)
if (result && !result.error) {
return Astro.redirect('/account')
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<MiniLayout
pageTitle={`Edit Profile - ${user.name}`}
description="Edit your user profile"
ogImage={{ template: 'generic', title: 'Edit Profile' }}
layoutHeader={{
icon: 'ri:edit-line',
title: 'Edit profile',
subtitle: 'Update your account information',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Edit profile',
},
]}
>
<form method="POST" action={actions.account.update} class="space-y-4" enctype="multipart/form-data">
<input transition:persist type="hidden" name="id" value={user.id} />
<div>
<label class="text-day-200 mb-2 block text-sm" for="displayName">Display Name</label>
<input
transition:persist
type="text"
id="displayName"
name="displayName"
value={user.displayName ?? ''}
maxlength={100}
class="border-day-500/30 bg-night-800 text-day-300 placeholder:text-day-500 focus:border-day-500 focus:ring-day-500 w-full rounded-md border px-3 py-2 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!user.karmaUnlocks.displayName}
/>
{
inputErrors.displayName && (
<p class="mt-1 text-sm text-red-400">{inputErrors.displayName.join(', ')}</p>
)
}
{
!user.karmaUnlocks.displayName && (
<p class="text-day-400 mt-2 flex items-center gap-2 rounded-md text-sm">
<Icon name="ri:information-line" class="size-4" />
{makeKarmaUnlockMessage(karmaUnlocksById.displayName)}
<a href="/karma" class="hover:text-day-300 underline">
Learn about karma
</a>
</p>
)
}
</div>
<div>
<label class="text-day-200 mb-2 block text-sm" for="link">Website URL</label>
<input
transition:persist
type="url"
id="link"
name="link"
value={user.link ?? ''}
placeholder="https://example.com"
class="border-day-500/30 bg-night-800 text-day-300 placeholder:text-day-500 focus:border-day-500 focus:ring-day-500 w-full rounded-md border px-3 py-2 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!user.karmaUnlocks.websiteLink}
/>
{inputErrors.link && <p class="mt-1 text-sm text-red-400">{inputErrors.link.join(', ')}</p>}
{
!user.karmaUnlocks.websiteLink && (
<p class="text-day-400 mt-2 flex items-center gap-2 rounded-md text-sm">
<Icon name="ri:information-line" class="size-4" />
{makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)}
<a href="/karma" class="hover:text-day-300 underline">
Learn about karma
</a>
</p>
)
}
</div>
<div>
<label class="text-day-200 mb-2 block text-sm" for="pictureFile"> Profile Picture </label>
<div class="mt-2 space-y-2">
<input
transition:persist
type="file"
name="pictureFile"
id="pictureFile"
accept="image/*"
class="border-day-500/30 bg-night-800 text-day-300 file:bg-day-500/30 file:text-day-300 focus:border-day-500 focus:ring-day-500 block w-full rounded-md border p-2 file:mr-3 file:rounded-md file:border-0 file:px-3 file:py-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={!user.karmaUnlocks.profilePicture}
/>
<p class="text-day-400 text-xs">
Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size: 5MB.
</p>
</div>
{
inputErrors.pictureFile && (
<p class="mt-1 text-sm text-red-400">{inputErrors.pictureFile.join(', ')}</p>
)
}
{
!user.karmaUnlocks.profilePicture && (
<p class="text-day-400 mt-2 flex items-center gap-2 rounded-md text-sm">
<Icon name="ri:information-line" class="size-4" />
You need 200 karma to have a profile picture.
<a href="/karma" class="hover:text-day-300 underline">
Learn about karma
</a>
</p>
)
}
</div>
<Button
type="submit"
icon="ri:save-line"
label="Save"
color="success"
shadow
size="md"
class="mt-4 w-full"
/>
</form>
</MiniLayout>

View File

@@ -0,0 +1,117 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../../components/Button.astro'
import Captcha from '../../components/Captcha.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { callActionWithObject } from '../../lib/callActionWithUrlParams'
import { prettifyUserSecretToken } from '../../lib/userSecretToken'
const generateResult = Astro.getActionResult(actions.account.generate)
if (generateResult && !generateResult.error) {
return Astro.rewrite('/account/welcome')
}
const data = await callActionWithObject(Astro, actions.account.preGenerateToken, undefined, 'form')
const preGeneratedToken = data?.token
const prettyToken = preGeneratedToken ? prettifyUserSecretToken(preGeneratedToken) : undefined
---
{/* eslint-disable astro/jsx-a11y/no-autofocus */}
<MiniLayout
pageTitle="Create Account"
description="Create a new account"
ogImage={{ template: 'generic', title: 'Create Account' }}
layoutHeader={{
icon: 'ri:user-add-line',
title: 'New account',
subtitle: 'Zero data, 100% anonymous',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Create account',
},
]}
>
{
Astro.locals.user && (
<div class="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
<Icon name="ri:alert-line" class="size-5 text-red-400" />
<p class="text-sm text-red-400">You will be logged out of your current account.</p>
</div>
)
}
<form method="POST" action={`/account/welcome${actions.account.generate}`}>
{/* Hack to make password managers suggest saving the secret token */}
<div class="-z-50 m-0 mb-2 grid h-4 grid-cols-2">
<input
class="block cursor-default border-none bg-transparent text-transparent outline-hidden"
type="text"
id="username"
name="username"
value={prettyToken}
autocomplete="off"
tabindex="-1"
data-override-value-hack
data-override-value={prettyToken}
/>
<input
class="block cursor-default border-none bg-transparent text-transparent outline-hidden"
type="password"
id="password"
name="password"
value={prettyToken}
autocomplete="off"
tabindex="-1"
data-override-value-hack
data-override-value={prettyToken}
/>
</div>
<input type="hidden" name="secret-token" value={preGeneratedToken} autocomplete="off" />
<Captcha action={actions.account.generate} autofocus />
<InputHoneypotTrap name="message" />
<Button
type="submit"
label="Create account"
icon="ri:user-add-line"
class="mt-14 flex w-full"
color="success"
shadow
size="lg"
/>
</form>
</MiniLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for password manager integration. //
// Makes password managers suggest saving the secret //
// token by using hidden username/password fields. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-override-value-hack]')
inputs.forEach((input) => {
input.addEventListener('focus', () => {
input.blur()
})
input.addEventListener('input', () => {
input.value = input.dataset.overrideValue ?? ''
})
})
})
</script>

View File

@@ -0,0 +1,32 @@
---
import { actions } from 'astro:actions'
import { stopImpersonating } from '../../lib/impersonation'
import { urlParamsToFormData } from '../../lib/urls'
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const redirectUrl = Astro.url.searchParams.get('redirect') || Astro.request.headers.get('referer') || '/'
const stop = Astro.url.searchParams.get('stop')
if (stop) {
await stopImpersonating(Astro)
return Astro.redirect(redirectUrl)
}
const alreadyImpersonating = !!Astro.locals.actualUser
if (alreadyImpersonating) return Astro.redirect(redirectUrl)
const user = Astro.locals.user
if (!user?.admin) return Astro.rewrite('/404')
const impersonateResult = await Astro.callAction(
actions.account.impersonate,
urlParamsToFormData(Astro.url.searchParams)
)
if (!impersonateResult.error) {
return Astro.redirect(impersonateResult.data.redirect)
}
return Astro.rewrite(`/500?message=${encodeURIComponent(impersonateResult.error.message)}`)
---

View File

@@ -0,0 +1,908 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import BadgeSmall from '../../components/BadgeSmall.astro'
import Button from '../../components/Button.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import { karmaUnlocks, karmaUnlocksById } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import { getServiceUserRoleInfo } from '../../constants/serviceUserRoles'
import { verificationStatusesByValue } from '../../constants/verificationStatus'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
import { formatDateShort } from '../../lib/timeAgo'
const userId = Astro.locals.user?.id
if (!userId) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to view your account' }))
}
const updateResult = Astro.getActionResult(actions.account.update)
Astro.locals.banners.addIfSuccess(updateResult, 'Profile updated successfully')
const user = await Astro.locals.banners.try('user', async () => {
return makeUserWithKarmaUnlocks(
await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
displayName: true,
link: true,
picture: true,
spammer: true,
verified: true,
admin: true,
verifier: true,
verifiedLink: true,
totalKarma: true,
createdAt: true,
_count: {
select: {
comments: true,
commentVotes: true,
karmaTransactions: true,
},
},
karmaTransactions: {
select: {
id: true,
points: true,
action: true,
description: true,
createdAt: true,
comment: {
select: {
id: true,
content: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
suggestions: {
select: {
id: true,
type: true,
status: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
comments: {
select: {
id: true,
content: true,
createdAt: true,
upvotes: true,
status: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
commentVotes: {
select: {
id: true,
downvote: true,
createdAt: true,
comment: {
select: {
id: true,
content: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
serviceAffiliations: {
select: {
role: true,
service: {
select: {
id: true,
name: true,
slug: true,
imageUrl: true,
verificationStatus: true,
},
},
},
orderBy: [{ role: 'asc' }, { service: { name: 'asc' } }],
},
},
})
)
})
if (!user) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle={`${user.name} - Account`}
description="Manage your user profile"
ogImage={{ template: 'generic', title: `${user.name} | Account` }}
widthClassName="max-w-screen-md"
className={{
main: 'space-y-6',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'My account',
},
]}
>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4">
{
user.picture ? (
<img src={user.picture} alt="" class="ring-day-500/30 size-16 rounded-full ring-2" />
) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" />
</div>
)
}
<div>
<h1 class="font-title text-lg font-bold tracking-wider text-white">{user.name}</h1>
{user.displayName && <p class="text-day-200">{user.displayName}</p>}
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div>
</div>
<nav class="ml-auto flex items-center gap-2">
<Tooltip
as="a"
href={`/u/${user.name}`}
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border p-2 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="View public profile"
>
<Icon name="ri:global-line" class="size-4" />
</Tooltip>
<Tooltip
as="a"
href="/account/logout"
data-astro-prefetch="tap"
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border p-2 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="Logout"
>
<Icon name="ri:logout-box-r-line" class="size-4" />
</Tooltip>
<Tooltip
as="a"
text="Edit Profile"
href="/account/edit"
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border p-2 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
</nav>
</header>
<div class="border-night-400 mt-6 border-t pt-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 class="font-title text-day-200 mb-4 text-sm">Profile Information</h3>
<ul class="flex flex-col items-start gap-2">
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:user-3-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Username</p>
<p class="text-day-300">{user.name}</p>
</div>
</li>
<Tooltip as="li" text="Unlock with more karma" class="inline-flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:user-smile-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">
Display Name {
!user.karmaUnlocks.displayName && (
<Icon name="ri:lock-line" class="inline-block size-3 align-[-0.15em]" />
)
}
</p>
<p class="text-day-300">
{user.displayName ?? <span class="text-sm italic">Not set</span>}
</p>
</div>
</Tooltip>
<Tooltip as="li" text="Unlock with more karma" class="inline-flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:link" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">
Website {
!user.karmaUnlocks.websiteLink && (
<Icon name="ri:lock-line" class="inline-block size-3 align-[-0.15em]" />
)
}
</p>
<p class="text-day-300">
{
user.link ? (
<a
href={user.link}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.link}
</a>
) : (
<span class="text-sm italic">Not set</span>
)
}
</p>
</div>
</Tooltip>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Karma</p>
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p>
</div>
</li>
</ul>
</div>
<div id="account-status">
<h3 class="font-title text-day-200 mb-4 text-sm">Account Status</h3>
<ul class="space-y-3">
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:shield-check-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Account Type</p>
<div class="mt-1 flex flex-wrap gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
Verified User
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Verifier
</span>
)
}
{
!user.admin && !user.verified && !user.verifier && (
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
Standard User
</span>
)
}
</div>
</div>
</li>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:spam-2-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Spam Status</p>
{
user.spammer ? (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Spammer
</span>
) : (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Not Flagged
</span>
)
}
</div>
</li>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Joined</p>
<p class="text-day-300">
{
formatDateShort(user.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
{
user.verifiedLink && (
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:check-double-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Verified as related to</p>
<a
href={user.verifiedLink}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.verifiedLink}
</a>
</div>
</li>
)
}
<li>
<Button
as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=User verification request - ${user.name}&body=I would like to be verified as related to https://www.example.com`}
label="Request verification"
size="sm"
/>
</li>
</ul>
</div>
</div>
</div>
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
Service Affiliations
</h2>
<Button
as="a"
href={`mailto:${SUPPORT_EMAIL}?subject=Service Affiliation Verification Request - ${user.name}&body=I would like to be verified as related to the services ACME as Admin and XYZ as Team Member. Here is the proof...`}
label="Request"
size="md"
/>
</header>
{
user.serviceAffiliations.length > 0 ? (
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
const statusIcon = {
...verificationStatusesByValue,
APPROVED: undefined,
}[affiliation.service.verificationStatus]
return (
<li class="shrink-0">
<a
href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
>
<Picture
src={affiliation.service.imageUrl ?? (defaultServiceImage as unknown as string)}
alt={affiliation.service.name}
width={40}
height={40}
class="size-10 shrink-0 rounded-lg"
/>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span>
</div>
<div class="text-day-300 flex items-center gap-1 font-semibold">
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
{affiliation.service.name}
</span>
{statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
<Icon
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1',
statusIcon.classNames.icon
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)
}
</section>
<section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-unlocks"
>
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
Karma Unlocks
</h2>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
>Learn about karma</a
>
</p>
</div>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
<div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))
}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
</div>
</div>
</section>
<div class="space-y-6">
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:chat-3-line" class="mr-2 inline-block size-5" />
Recent Comments
</h2>
<span class="text-day-500">{user._count.comments.toLocaleString()} comments</span>
</header>
{
user.comments.length === 0 ? (
<p class="text-day-400">No comments yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Comment</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Upvotes</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.comments.map((comment) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a href={`/service/${comment.service.slug}`} class="text-blue-400 hover:underline">
{comment.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3">
<p class="line-clamp-1">{comment.content}</p>
</td>
<td class="px-4 py-3 text-center">
<span class="border-day-700/20 bg-night-800/30 text-day-300 inline-flex rounded-full border px-2 py-0.5 text-xs">
{comment.status}
</span>
</td>
<td class="text-day-300 px-4 py-3 text-center text-xs">
<span class="inline-flex items-center">
<Icon name="ri:thumb-up-line" class="mr-1 size-4" /> {comment.upvotes}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={comment.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
{
user.karmaUnlocks.voteComments || user._count.commentVotes ? (
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:thumb-up-line" class="mr-2 inline-block size-5" />
Recent Votes
</h2>
<span class="text-day-500">{user._count.commentVotes.toLocaleString()} votes</span>
</header>
{user.commentVotes.length === 0 ? (
<p class="text-day-400">No votes yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Comment</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Vote</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.commentVotes.map((vote) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service/${vote.comment.service.slug}`}
class="text-blue-400 hover:underline"
>
{vote.comment.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3">
<p class="line-clamp-1">{vote.comment.content}</p>
</td>
<td class="px-4 py-3 text-center">
{vote.downvote ? (
<span class="inline-flex items-center text-red-400">
<Icon name="ri:thumb-down-fill" class="size-4" />
</span>
) : (
<span class="inline-flex items-center text-green-400">
<Icon name="ri:thumb-up-fill" class="size-4" />
</span>
)}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={vote.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
) : (
<section class="border-night-400 bg-night-400/5 flex flex-wrap items-center justify-between gap-2 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<h2 class="font-title text-day-200 grow-9999 text-xl font-bold">
<Icon name="ri:thumb-up-line" class="mr-2 inline-block size-5" />
Recent Votes
</h2>
<Tooltip
text={`Get ${(karmaUnlocksById.voteComments.karma - user.totalKarma).toLocaleString()} more karma to unlock`}
class="text-day-500 inline-flex grow items-center justify-center gap-1"
>
<Icon name="ri:lock-line" class="inline-block size-5" />
Locked
</Tooltip>
</section>
)
}
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
Recent Suggestions
</h2>
<a
href="/service-suggestion"
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
View all
</a>
</header>
{
user.suggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.suggestions.map((suggestion) => {
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline"
>
{suggestion.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={suggestion.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
Recent Karma Transactions
</h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{
user.karmaTransactions.length === 0 ? (
<p class="text-day-400">No karma transactions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">{transaction.action}</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
{new Date(transaction.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
</div>
<nav class="mt-6 flex items-center justify-center gap-4">
<a
href="/account/logout"
data-astro-prefetch="tap"
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
</a>
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
</a>
</nav>
</BaseLayout>

View File

@@ -0,0 +1,82 @@
---
import { actions } from 'astro:actions'
import Button from '../../components/Button.astro'
import InputLoginToken from '../../components/InputLoginToken.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { logout } from '../../lib/userCookies'
const result = Astro.getActionResult(actions.account.login)
if (result && !result.error) {
return Astro.redirect(result.data.redirect)
}
if (Astro.url.searchParams.get('logout')) {
await logout(Astro)
const url = new URL(Astro.url)
url.searchParams.delete('logout')
return Astro.redirect(url.toString())
}
// Redirect if already logged in
if (Astro.locals.user) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return Astro.redirect(Astro.url.searchParams.get('redirect') || '/')
}
const message = Astro.url.searchParams.get('message')
---
<MiniLayout
pageTitle="Login"
description="Login to your account"
ogImage={{ template: 'generic', title: 'Login' }}
layoutHeader={{
icon: 'ri:user-line',
title: 'Welcome back',
subtitle: message ?? 'Enter your login key',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Login',
},
]}
>
<form method="POST" action={actions.account.login} aria-label="Log in">
{/* eslint-disable-next-line astro/jsx-a11y/no-autofocus */}
<InputLoginToken name="token" autofocus />
<Button
type="submit"
label="Login"
icon="ri:login-box-line"
class="mt-4 w-full"
color="success"
shadow
size="lg"
/>
</form>
<div
class="before:bg-day-500/30 after:bg-day-500/30 xs:my-8 my-6 flex items-center gap-2 before:h-px before:w-full after:h-px after:w-full"
>
<span class="text-day-400 leading-none">or</span>
</div>
<p class="text-day-500 -mt-2 mb-1 text-center">Don't have an anonymous account?</p>
<Button
as="a"
href="/account/generate"
dataAstroReload
label="Create account"
icon="ri:user-add-line"
class="w-full"
color="gray"
size="lg"
/>
</MiniLayout>

View File

@@ -0,0 +1,8 @@
---
import { logout } from '../../lib/userCookies'
await logout(Astro)
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return Astro.redirect(Astro.url.searchParams.get('redirect') || Astro.request.headers.get('referer') || '/')
---

View File

@@ -0,0 +1,114 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../../components/Button.astro'
import CopyButton from '../../components/CopyButton.astro'
import { SUPPORT_EMAIL } from '../../constants/project'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { prettifyUserSecretToken } from '../../lib/userSecretToken'
const result = Astro.getActionResult(actions.account.generate)
if (result?.error) return Astro.redirect('/account/generate')
const prettyToken = result ? prettifyUserSecretToken(result.data.token) : null
---
<MiniLayout
pageTitle="Welcome"
description="New account welcome page"
ogImage={{ template: 'generic', title: 'Welcome' }}
layoutHeader={{
icon: 'ri:key-2-line',
title: 'Save your Login Key',
subtitle: 'You need it to login to your account',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Welcome',
},
]}
>
{
prettyToken ? (
<>
<div class="mb-2 flex items-center gap-3 rounded-md bg-red-900/80 p-2 text-red-200">
<Icon name="ri:delete-bin-line" class="size-5 flex-shrink-0" />
<p class="text-sm text-pretty">Won't show again and can't be recovered!</p>
</div>
<div class="border-day-800 js:pr-24 bg-night-800 relative rounded-md border p-3 font-mono break-all text-white">
{prettyToken}
<div class="absolute inset-y-0 right-2 flex items-center">
<CopyButton copyText={prettyToken} size="sm" color="success" />
</div>
</div>
<p class="text-day-500 mt-1 text-center text-sm text-balance">
Save it in a <span class="text-day-200 font-medium">password manager</span>
or <span class="text-day-200 font-medium">secure location</span>
</p>
<form method="GET" action="/" class="mt-8 space-y-4">
<div class="flex items-center gap-4">
<input type="checkbox" id="confirm-saved" required />
<label for="confirm-saved" class="text-day-400 text-sm text-pretty">
I saved my Login Key
</label>
</div>
<Button type="submit" class="w-full" color="gray" label="Continue" />
</form>
</>
) : (
<>
<div class="mt-12 flex items-center justify-center gap-3 rounded-md bg-red-900/80 p-3 text-red-200">
<Icon name="ri:alert-line" class="size-5 flex-shrink-0" />
<p class="text-sm text-pretty">Your Login Key can't be shown again</p>
</div>
<p class="text-day-300 mt-2 text-center text-sm text-balance">
If you lost it, contact us at
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-green-400 hover:underline focus-visible:underline">
{SUPPORT_EMAIL}
</a>
</p>
</>
)
}
</MiniLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for preventing accidental navigation. //
// Shows a warning if the user tries to leave without //
// confirming they saved their token. //
////////////////////////////////////////////////////////////
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
event.preventDefault()
// Included for legacy support, e.g. Chrome/Edge < 119
event.returnValue = true
}
window.addEventListener('beforeunload', beforeUnloadHandler)
document.addEventListener('astro:after-swap', () => {
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
document.addEventListener('astro:page-load', () => {
const confirmSavedInput = document.querySelectorAll<HTMLInputElement>('#confirm-saved')
confirmSavedInput.forEach((input) => {
input.addEventListener('input', () => {
if (input.checked) {
window.addEventListener('beforeunload', beforeUnloadHandler)
} else {
window.removeEventListener('beforeunload', beforeUnloadHandler)
}
})
})
})
</script>

View File

@@ -0,0 +1,751 @@
---
import { AttributeCategory, AttributeType, type Prisma } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../components/SortArrowIcon.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { formatNumber } from '../../lib/numbers'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
const search = Astro.url.searchParams.get('search') ?? ''
const categoryFilter = z
.nativeEnum(AttributeCategory)
.nullable()
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.parse(Astro.url.searchParams.get('category') || null)
const typeFilter = z
.nativeEnum(AttributeType)
.nullable()
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.parse(Astro.url.searchParams.get('type') || null)
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z
.enum(['title', 'category', 'type', 'privacyPoints', 'trustPoints', 'serviceCount'])
.default('title'),
'sort-order': z.enum(['asc', 'desc']).default('asc'),
},
Astro
)
const createResult = Astro.getActionResult(actions.admin.attribute.create)
Astro.locals.banners.addIfSuccess(createResult, 'Attribute created successfully')
const createInputErrors = isInputError(createResult?.error) ? createResult.error.fields : {}
const updateResult = Astro.getActionResult(actions.admin.attribute.update)
Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully')
const updatedAttributeId = updateResult?.data?.attribute.id ?? null
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
const whereClause: Prisma.AttributeWhereInput = {
...(search
? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
],
}
: {}),
category: categoryFilter ?? undefined,
type: typeFilter ?? undefined,
}
let prismaOrderBy: Record<string, 'asc' | 'desc'> = { title: 'asc' } // Default sort
// Ensure sortBy is a valid key for Attribute model for Prisma ordering
const validPrismaSortKeys = ['title', 'slug', 'privacyPoints', 'trustPoints', 'createdAt', 'updatedAt'] // Add other valid direct fields if needed
if (validPrismaSortKeys.includes(sortBy)) {
prismaOrderBy = { [sortBy]: sortOrder }
} else {
// If sortBy is not a direct DB field (like category, type, serviceCount),
// Prisma will use its default (title: asc) or the last valid key set.
// The actual sorting for these custom cases will happen in JS after fetch.
if (sortBy === 'category' || sortBy === 'type' || sortBy === 'serviceCount') {
// Keep default prisma sort, JS will handle it
} else {
// Fallback if an unexpected sort key is provided, perhaps log this or handle error
console.warn(`Unsupported Prisma sort key: ${sortBy}. Defaulting to title sort.`)
prismaOrderBy = { title: 'asc' }
}
}
let attributes = await Astro.locals.banners.try(
'Error fetching attributes',
async () =>
prisma.attribute.findMany({
where: whereClause,
orderBy: prismaOrderBy,
include: {
services: {
select: {
serviceId: true,
},
},
},
}),
[]
)
let attributesWithDetails = attributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
serviceCount: attribute.services.length,
}))
if (sortBy === 'category') {
attributesWithDetails = lodashOrderBy(
attributesWithDetails,
[(item) => item.categoryInfo.order],
[sortOrder]
)
} else if (sortBy === 'type') {
attributesWithDetails = lodashOrderBy(attributesWithDetails, [(item) => item.typeInfo.order], [sortOrder])
} else if (sortBy === 'serviceCount') {
attributesWithDetails = lodashOrderBy(attributesWithDetails, ['serviceCount'], [sortOrder])
}
const attributeCount = attributesWithDetails.length
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/attributes?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="Admin | Attributes" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">Attribute Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{attributeCount} attributes</span>
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
onclick="document.getElementById('create-attribute-form').classList.toggle('hidden')"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Attribute</span>
</button>
</div>
</div>
<!-- Create Attribute Form (hidden by default, shown via button) -->
<div
id="create-attribute-form"
class="mb-6 hidden rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg"
>
<h2 class="font-title mb-3 text-lg font-semibold text-green-400">Create New Attribute</h2>
<form method="POST" action={actions.admin.attribute.create} class="space-y-4">
<div>
<label for="title-create" class="block text-sm font-medium text-zinc-300">Title</label>
<input
transition:persist
type="text"
name="title"
id="title-create"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.title && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.title.join(', ')}</p>
)
}
</div>
<div>
<label for="description-create" class="block text-sm font-medium text-zinc-300">Description</label>
<textarea
transition:persist
name="description"
id="description-create"
required
rows="3"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
></textarea>
{
createInputErrors.description && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.description.join(', ')}</p>
)
}
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="slug-create" class="block text-sm font-medium text-zinc-300">Slug</label>
<input
transition:persist
type="text"
name="slug"
id="slug-create"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="privacyPoints-create" class="block text-sm font-medium text-zinc-300">Privacy</label>
<input
transition:persist
type="number"
name="privacyPoints"
id="privacyPoints-create"
min="-100"
max="100"
value="0"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.privacyPoints && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.privacyPoints.join(', ')}</p>
)
}
</div>
<div>
<label for="trustPoints-create" class="block text-sm font-medium text-zinc-300">Trust</label>
<input
transition:persist
type="number"
name="trustPoints"
id="trustPoints-create"
min="-100"
max="100"
value="0"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-green-500 focus:ring-green-500 focus:outline-none"
/>
{
createInputErrors.trustPoints && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.trustPoints.join(', ')}</p>
)
}
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="category-create" class="block text-sm font-medium text-zinc-300">Category</label>
<select
transition:persist
name="category"
id="category-create"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-green-500 focus:ring-green-500 focus:outline-none"
>
{
Object.values(AttributeCategory).map((category) => (
<option value={category}>{getAttributeCategoryInfo(category).label}</option>
))
}
</select>
{
createInputErrors.category && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.category.join(', ')}</p>
)
}
</div>
<div>
<label for="type-create" class="block text-sm font-medium text-zinc-300">Type</label>
<select
transition:persist
name="type"
id="type-create"
required
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-green-500 focus:ring-green-500 focus:outline-none"
>
{
Object.values(AttributeType).map((type) => (
<option value={type}>{getAttributeTypeInfo(type).label}</option>
))
}
</select>
{
createInputErrors.type && (
<p class="mt-1 text-sm text-red-400">{createInputErrors.type.join(', ')}</p>
)
}
</div>
</div>
<button
type="submit"
class="w-full rounded-md bg-green-600 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Create Attribute
</button>
</form>
</div>
<!-- Filters Bar -->
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
<form method="GET" class="grid gap-3 md:grid-cols-2 lg:grid-cols-4" autocomplete="off">
<div class="lg:col-span-2">
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={search}
placeholder="Search by title, description, slug..."
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="category-filter" class="block text-xs font-medium text-zinc-400">Category</label>
<select
name="category"
id="category-filter"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Categories</option>
{
Object.values(AttributeCategory).map((cat) => (
<option value={cat} selected={categoryFilter === cat}>
{getAttributeCategoryInfo(cat).label}
</option>
))
}
</select>
</div>
<div>
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
<div class="mt-1 flex">
<select
name="type"
id="type-filter"
class="w-full rounded-l-md border border-r-0 border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Types</option>
{
Object.values(AttributeType).map((attrType) => (
<option value={attrType} selected={typeFilter === attrType}>
{getAttributeTypeInfo(attrType).label}
</option>
))
}
</select>
<button
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
</div>
</div>
</form>
</div>
<!-- Attribute List -->
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
<h2 class="font-title font-semibold text-blue-400">Attributes List</h2>
<div class="mt-1 text-xs text-zinc-400 md:hidden">
<span>Scroll horizontally to see more →</span>
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="min-w-[750px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
scope="col"
class="w-[30%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('title')} class="flex items-center hover:text-zinc-200">
Title <SortArrowIcon
active={filters['sort-by'] === 'title'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('category')} class="flex items-center hover:text-zinc-200">
Category <SortArrowIcon
active={filters['sort-by'] === 'category'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('type')} class="flex items-center hover:text-zinc-200">
Type <SortArrowIcon
active={filters['sort-by'] === 'type'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('privacyPoints')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Privacy</span>
<span class="sm:hidden">Priv</span>
<SortArrowIcon
active={filters['sort-by'] === 'privacyPoints'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('trustPoints')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Trust</span>
<span class="sm:hidden">Tr</span>
<SortArrowIcon
active={filters['sort-by'] === 'trustPoints'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('serviceCount')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Services</span>
<span class="sm:hidden">Svcs</span>
<SortArrowIcon
active={filters['sort-by'] === 'serviceCount'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
scope="col"
class="w-[10%] px-4 py-3 text-right text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
{
attributesWithDetails.map((attribute, index) => (
<>
<tr id={`attribute-${attribute.id}`} class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div class="truncate" title={attribute.title}>
{attribute.title}
</div>
<div class="text-2xs text-zinc-500">{attribute.slug}</div>
</td>
<td class="px-4 py-3">
<span
class={cn(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
attribute.categoryInfo.classNames.icon
)}
>
<Icon name={attribute.categoryInfo.icon} class="mr-1 size-3" />
{attribute.categoryInfo.label}
</span>
</td>
<td class="px-4 py-3">
<span
class={cn(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
attribute.typeInfo.classNames.icon
)}
>
<Icon name={attribute.typeInfo.icon} class="mr-1 size-3" />
{attribute.typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn('font-medium', {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'text-zinc-500': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn('font-medium', {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'text-zinc-500': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-zinc-700 px-2.5 py-0.5 text-xs font-medium text-zinc-300">
{attribute.serviceCount}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-blue-500/50 bg-blue-500/20 p-1.5 text-blue-400 transition-colors hover:bg-blue-500/30 focus:outline-none"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
title="Edit attribute"
>
<Icon name="ri:edit-line" class="size-3.5" />
</button>
<form method="POST" action={actions.admin.attribute.delete} class="inline-block">
<input type="hidden" name="id" value={attribute.id} />
<button
type="submit"
class="inline-flex items-center justify-center rounded-md border border-red-500/50 bg-red-500/20 p-1.5 text-red-400 transition-colors hover:bg-red-500/30 focus:outline-none"
onclick="return confirm('Are you sure you want to delete this attribute?')"
title="Delete attribute"
>
<Icon name="ri:delete-bin-line" class="size-3.5" />
</button>
</form>
</div>
</td>
</tr>
<tr id={`edit-form-${index}`} class="hidden bg-zinc-700/20">
<td colspan="7" class="p-4">
<h3 class="font-title text-md mb-2 font-semibold text-blue-300">
Edit: {attribute.title}
</h3>
<form method="POST" action={actions.admin.attribute.update} class="space-y-4">
<input type="hidden" name="id" value={attribute.id} />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
for={`title-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Title
</label>
<input
type="text"
name="title"
id={`title-edit-${index}`}
required
value={attribute.title}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for={`slug-edit-${index}`} class="block text-sm font-medium text-zinc-300">
Slug
</label>
<input
type="text"
name="slug"
id={`slug-edit-${index}`}
required
value={attribute.slug}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label
for={`description-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Description
</label>
<textarea
name="description"
id={`description-edit-${index}`}
required
rows="3"
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{attribute.description}
</textarea>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<label
for={`privacyPoints-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Privacy
</label>
<input
type="number"
name="privacyPoints"
id={`privacyPoints-edit-${index}`}
min="-100"
max="100"
value={attribute.privacyPoints}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label
for={`trustPoints-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Trust
</label>
<input
type="number"
name="trustPoints"
id={`trustPoints-edit-${index}`}
min="-100"
max="100"
value={attribute.trustPoints}
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label
for={`category-edit-${index}`}
class="block text-sm font-medium text-zinc-300"
>
Category
</label>
<select
name="category"
id={`category-edit-${index}`}
required
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{Object.values(AttributeCategory).map((category) => (
<option value={category} selected={attribute.category === category}>
{getAttributeCategoryInfo(category).label}
</option>
))}
</select>
</div>
<div>
<label for={`type-edit-${index}`} class="block text-sm font-medium text-zinc-300">
Type
</label>
<select
name="type"
id={`type-edit-${index}`}
required
class="mt-1 w-full rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{Object.values(AttributeType).map((type) => (
<option value={type} selected={attribute.type === type}>
{getAttributeTypeInfo(type).label}
</option>
))}
</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
type="button"
class="rounded-md border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 focus:outline-none"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
Save Changes
</button>
</div>
</form>
</td>
</tr>
</>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<script define:vars={{ updatedAttributeId }}></script>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
/* Base CSS text size utility */
.text-2xs {
font-size: 0.6875rem; /* 11px */
line-height: 1rem; /* 16px */
}
/* Scrollbar styling for better mobile experience */
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.6);
}
@media (max-width: 768px) {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
}
</style>

View File

@@ -0,0 +1,237 @@
---
import { z } from 'astro/zod'
import CommentModeration from '../../components/CommentModeration.astro'
import {
commentStatusFilters,
commentStatusFiltersZodEnum,
getCommentStatusFilterInfo,
getCommentStatusFilterValue,
} from '../../constants/commentStatusFilters'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { urlWithParams } from '../../lib/urls'
const user = Astro.locals.user
if (!user || (!user.admin && !user.verifier)) {
return Astro.rewrite('/404')
}
const { data: params } = zodParseQueryParamsStoringErrors(
{
status: commentStatusFiltersZodEnum.default('all'),
page: z.number().int().positive().default(1),
},
Astro
)
const PAGE_SIZE = 20
const statusFilter = getCommentStatusFilterInfo(params.status)
const [comments = [], totalComments = 0] = await Astro.locals.banners.try(
'Error fetching comments',
async () =>
prisma.comment.findManyAndCount({
where: statusFilter.whereClause,
include: {
author: true,
service: {
select: {
name: true,
slug: true,
},
},
parent: {
include: {
author: true,
},
},
votes: true,
},
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
skip: (params.page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
[]
)
const totalPages = Math.ceil(totalComments / PAGE_SIZE)
---
<BaseLayout pageTitle="Comment Moderation" widthClassName="max-w-screen-xl">
<div class="mb-8">
<h1 class="font-title mb-4 text-2xl text-green-500">&gt; comments.moderate</h1>
<!-- Status Filters -->
<div class="flex flex-wrap gap-2">
{
commentStatusFilters.map((filter) => (
<a
href={urlWithParams(Astro.url, { status: filter.value })}
class={cn([
'font-title rounded-md border px-3 py-1 text-sm',
params.status === filter.value
? filter.styles.filter
: 'border-zinc-700 transition-colors hover:border-green-500/50',
])}
>
{filter.label}
</a>
))
}
</div>
</div>
<!-- Comments List -->
<div class="space-y-6">
{
comments
.map((comment) => ({
...comment,
statusFilterInfo: getCommentStatusFilterInfo(getCommentStatusFilterValue(comment)),
}))
.map((comment) => (
<div
id={`comment-${comment.id.toString()}`}
class="rounded-lg border border-zinc-800 bg-black/40 p-6 backdrop-blur-xs"
>
<div class="mb-4 flex flex-wrap items-center gap-2">
{/* Author Info */}
<span class="font-title text-sm">{comment.author.name}</span>
{comment.author.admin && (
<span class="rounded-sm bg-yellow-500/20 px-2 py-0.5 text-[12px] font-medium text-yellow-500">
admin
</span>
)}
{comment.author.verified && !comment.author.admin && (
<span class="rounded-sm bg-blue-500/20 px-2 py-0.5 text-[12px] font-medium text-blue-500">
verified
</span>
)}
{comment.author.verifier && !comment.author.admin && (
<span class="rounded-sm bg-green-500/20 px-2 py-0.5 text-[12px] font-medium text-green-500">
verifier
</span>
)}
{/* Service Link */}
<span class="text-xs text-zinc-500">•</span>
<a
href={`/service/${comment.service.slug}#comment-${comment.id.toString()}`}
class="text-sm text-blue-400 transition-colors hover:text-blue-300"
>
{comment.service.name}
</a>
{/* Date */}
<span class="text-xs text-zinc-500">•</span>
<span class="text-sm text-zinc-400">{new Date(comment.createdAt).toLocaleString()}</span>
{/* Status Badges */}
<span class={comment.statusFilterInfo.styles.badge}>{comment.statusFilterInfo.label}</span>
</div>
{/* Parent Comment Context */}
{comment.parent && (
<div class="mb-4 border-l-2 border-zinc-700 pl-4">
<div class="mb-1 text-sm text-zinc-500">Replying to {comment.parent.author.name}:</div>
<div class="text-sm text-zinc-400">{comment.parent.content}</div>
</div>
)}
{/* Comment Content */}
<div class="mb-4 text-sm">{comment.content}</div>
{/* Notes */}
{comment.communityNote && (
<div class="mt-2">
<div class="text-sm font-medium text-green-500">Community Note</div>
<p class="text-sm text-gray-300">{comment.communityNote}</p>
</div>
)}
{comment.internalNote && (
<div class="mt-2">
<div class="text-sm font-medium text-yellow-500">Internal Note</div>
<p class="text-sm text-gray-300">{comment.internalNote}</p>
</div>
)}
{comment.privateContext && (
<div class="mt-2">
<div class="text-sm font-medium text-blue-500">Private Context</div>
<p class="text-sm text-gray-300">{comment.privateContext}</p>
</div>
)}
{comment.orderId && (
<div class="mt-2">
<div class="text-sm font-medium text-purple-500">Order ID</div>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-300">{comment.orderId}</p>
{comment.orderIdStatus && (
<span
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs',
comment.orderIdStatus === 'APPROVED' && 'bg-green-500/20 text-green-400',
comment.orderIdStatus === 'REJECTED' && 'bg-red-500/20 text-red-400',
comment.orderIdStatus === 'PENDING' && 'bg-blue-500/20 text-blue-400'
)}
>
{comment.orderIdStatus}
</span>
)}
</div>
</div>
)}
{(comment.kycRequested || comment.fundsBlocked) && (
<div class="mt-2">
<div class="text-sm font-medium text-red-500">Issue Flags</div>
<div class="flex flex-wrap gap-2">
{comment.kycRequested && (
<span class="rounded-sm bg-red-500/20 px-1.5 py-0.5 text-xs text-red-400">
KYC Requested
</span>
)}
{comment.fundsBlocked && (
<span class="rounded-sm bg-red-500/20 px-1.5 py-0.5 text-xs text-red-400">
Funds Blocked
</span>
)}
</div>
</div>
)}
<CommentModeration class="mt-2" comment={comment} />
</div>
))
}
</div>
<!-- Pagination -->
{
totalPages > 1 && (
<div class="mt-8 flex justify-center gap-2">
{params.page > 1 && (
<a
href={urlWithParams(Astro.url, { page: params.page - 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Previous
</a>
)}
<span class="font-title px-3 py-1 text-sm">
Page {params.page} of {totalPages}
</span>
{params.page < totalPages && (
<a
href={urlWithParams(Astro.url, { page: params.page + 1 })}
class="font-title rounded-md border border-zinc-700 px-3 py-1 text-sm transition-colors hover:border-green-500/50"
>
Next
</a>
)}
</div>
)
}
</BaseLayout>

View File

@@ -0,0 +1,76 @@
---
import { Icon } from 'astro-icon/components'
import BaseLayout from '../../layouts/BaseLayout.astro'
import type { ComponentProps } from 'astro/types'
type AdminLink = {
icon: ComponentProps<typeof Icon>['name']
title: string
href: string
description: string
}
const adminLinks: AdminLink[] = [
{
icon: 'ri:box-3-line',
title: 'Services',
href: '/admin/services',
description: 'Manage your available services',
},
{
icon: 'ri:file-list-3-line',
title: 'Attributes',
href: '/admin/attributes',
description: 'Configure service attributes',
},
{
icon: 'ri:user-3-line',
title: 'Users',
href: '/admin/users',
description: 'Manage user accounts',
},
{
icon: 'ri:chat-settings-line',
title: 'Comments',
href: '/admin/comments',
description: 'Moderate user comments',
},
{
icon: 'ri:lightbulb-line',
title: 'Service suggestions',
href: '/admin/service-suggestions',
description: 'Review and manage service suggestions',
},
]
---
<BaseLayout pageTitle="Admin Dashboard" widthClassName="max-w-screen-xl">
<h1 class="font-title mb-8 text-3xl font-bold text-zinc-100">
<Icon name="ri:home-gear-line" class="me-1 inline-block size-10 align-[-0.35em]" />
Admin Dashboard
</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{
adminLinks.map((link) => (
<a
href={link.href}
class="group block rounded-lg border border-zinc-800 bg-gradient-to-br from-zinc-900/90 to-zinc-900/50 p-6 shadow-lg backdrop-blur-xs transition-all duration-300 hover:-translate-y-0.5 hover:from-zinc-800/90 hover:to-zinc-800/50 hover:shadow-xl hover:shadow-zinc-900/20"
>
<div class="mb-4 flex items-center gap-3">
<Icon
name={link.icon}
class="h-6 w-6 text-zinc-400 transition-colors group-hover:text-green-400"
/>
<h2 class="font-title text-xl font-semibold text-zinc-100 transition-colors group-hover:text-green-400">
{link.title}
</h2>
</div>
<p class="text-sm text-zinc-400">{link.description}</p>
</a>
))
}
</div>
</BaseLayout>

View File

@@ -0,0 +1,195 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) {
return Astro.rewrite('/404')
}
const serviceSuggestion = await Astro.locals.banners.try('Error fetching service suggestion', async () =>
prisma.serviceSuggestion.findUnique({
where: {
id: serviceSuggestionId,
},
select: {
id: true,
status: true,
notes: true,
createdAt: true,
type: true,
user: {
select: {
id: true,
name: true,
},
},
service: {
select: {
id: true,
name: true,
slug: true,
description: true,
overallScore: true,
kycLevel: true,
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
},
messages: {
select: {
id: true,
content: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
})
)
if (!serviceSuggestion) {
return Astro.rewrite('/404')
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
---
<BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx
widthClassName="max-w-screen-md"
>
<div class="mb-4 flex items-center gap-4">
<a
href="/admin/service-suggestions"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:arrow-left-s-line" class="mr-1 size-4" />
Back
</a>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
</div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div>
<div
class="rounded-lg border border-green-500/30 bg-black/40 p-4 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Status:</span>
<span
class={cn(
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<span class="text-gray-300">
<a href={`/admin/users?name=${serviceSuggestion.user.name}`} class="hover:text-green-500">
{serviceSuggestion.user.name}
</a>
</span>
<span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="text-green-400 hover:text-green-500">
View Service <Icon
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
/>
</a>
</div>
{
serviceSuggestion.notes && (
<div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm whitespace-pre-wrap text-gray-300">
{serviceSuggestion.notes}
</div>
</div>
)
}
</div>
</div>
<div
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<div class="flex items-center justify-between">
<h2 class="font-title text-lg text-green-500">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<select
name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
>
<option value="PENDING" selected={serviceSuggestion.status === 'PENDING'}> Pending </option>
<option value="APPROVED" selected={serviceSuggestion.status === 'APPROVED'}> Approve </option>
<option value="REJECTED" selected={serviceSuggestion.status === 'REJECTED'}> Reject </option>
<option value="WITHDRAWN" selected={serviceSuggestion.status === 'WITHDRAWN'}> Withdrawn </option>
</select>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:save-line" class="mr-1 size-4" /> Update
</button>
</form>
</div>
<Chat
messages={serviceSuggestion.messages}
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
/>
</div>
</BaseLayout>

View File

@@ -0,0 +1,385 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const search = Astro.url.searchParams.get('search') ?? ''
const statusEnumValues = serviceSuggestionStatuses.map((s) => s.value) as [string, ...string[]]
const statusParam = Astro.url.searchParams.get('status')
const statusFilter = z
.enum(statusEnumValues)
.nullable()
.parse(statusParam === '' ? null : statusParam) as ServiceSuggestionStatus | null
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['service', 'status', 'user', 'createdAt', 'messageCount']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
},
Astro
)
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (sortBy === 'createdAt') {
prismaOrderBy = { createdAt: sortOrder }
}
let suggestions = await prisma.serviceSuggestion.findMany({
where: {
...(search
? {
OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } },
],
}
: {}),
status: statusFilter ?? undefined,
},
orderBy: prismaOrderBy,
select: {
id: true,
status: true,
notes: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
},
},
service: {
select: {
id: true,
name: true,
slug: true,
description: true,
imageUrl: true,
verificationStatus: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
},
messages: {
select: {
id: true,
content: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
_count: {
select: {
messages: true,
},
},
},
})
let suggestionsWithDetails = suggestions.map((s) => ({
...s,
statusInfo: getServiceSuggestionStatusInfo(s.status),
messageCount: s._count.messages,
lastMessage: s.messages[0],
}))
if (sortBy === 'service') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'status') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') {
suggestionsWithDetails = lodashOrderBy(
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[sortOrder]
)
} else if (sortBy === 'messageCount') {
suggestionsWithDetails = lodashOrderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
}
const suggestionCount = suggestionsWithDetails.length
const makeSortUrl = (slug: string) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/service-suggestions?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="Service Suggestions" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">Service Suggestions</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{suggestionCount} suggestions</span>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
<form method="GET" class="grid gap-3 md:grid-cols-2 lg:grid-cols-4" autocomplete="off">
<div class="lg:col-span-2">
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={search}
placeholder="Search by service, user, notes..."
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="status-filter" class="block text-xs font-medium text-zinc-400">Status</label>
<select
name="status"
id="status-filter"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Statuses</option>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}>
{status.label}
</option>
))
}
</select>
</div>
<div class="flex items-end">
<button
type="submit"
class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
</div>
</form>
</div>
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
<h2 class="font-title font-semibold text-blue-400">Suggestions List</h2>
<div class="mt-1 text-xs text-zinc-400 md:hidden">
<span>Scroll horizontally to see more →</span>
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="min-w-[900px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
class="w-[25%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon
active={filters['sort-by'] === 'service'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
User <SortArrowIcon
active={filters['sort-by'] === 'user'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
Status <SortArrowIcon
active={filters['sort-by'] === 'status'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('createdAt')} class="flex items-center hover:text-zinc-200">
Created <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('messageCount')}
class="flex items-center justify-center hover:text-zinc-200"
>
Messages <SortArrowIcon
active={filters['sort-by'] === 'messageCount'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[20%] px-4 py-3 text-right text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
{
suggestionsWithDetails.map((suggestion) => (
<tr id={`suggestion-${suggestion.id}`} class="group hover:bg-zinc-700/30">
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div class="truncate" title={suggestion.service.name}>
<a
href={`/service/${suggestion.service.slug}`}
class="flex items-center gap-1 hover:text-green-500"
>
{suggestion.service.name}
<Icon name="ri:external-link-line" class="inline-block size-4 align-[-0.05em]" />
</a>
</div>
<div
class="text-2xs max-w-[220px] truncate text-zinc-500"
title={suggestion.service.description}
>
{suggestion.service.description}
</div>
</td>
<td class="px-4 py-3">
<a href={`/admin/users?name=${suggestion.user.name}`} class="hover:text-green-500">
{suggestion.user.name}
</a>
</td>
<td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}>
<input type="hidden" name="suggestionId" value={suggestion.id} />
<select
name="status"
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
value={suggestion.status}
onchange="this.form.submit()"
title="Change status"
>
{serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={suggestion.status === status.value}>
{status.label}
</option>
))}
</select>
</form>
</td>
<td class="px-4 py-3 text-sm">
<TimeFormatted date={suggestion.createdAt} hourPrecision />
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-zinc-700 px-2.5 py-0.5 text-xs font-medium text-zinc-300">
{suggestion.messageCount}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-1">
<a
href={`/admin/service-suggestions/${suggestion.id}`}
class="inline-flex items-center justify-center rounded-full border border-green-500/40 bg-green-500/10 p-1.5 text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
title="View"
>
<Icon name="ri:external-link-line" class="size-4" />
</a>
</div>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
.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);
-webkit-overflow-scrolling: touch;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
---
import { ServiceVisibility, VerificationStatus, type Prisma } from '@prisma/client'
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import defaultImage from '../../../assets/fallback-service-image.jpg'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
search: z.string().optional(),
verificationStatus: z.nativeEnum(VerificationStatus).optional(),
visibility: z.nativeEnum(ServiceVisibility).optional(),
sort: z.enum(['name', 'createdAt', 'overallScore', 'verificationRequests']).default('name'),
order: z.enum(['asc', 'desc']).default('asc'),
page: z.coerce.number().int().positive().optional().default(1),
},
Astro
)
const itemsPerPage = 20
const sortProperty = filters.sort
const sortDirection = filters.order
let orderBy: Prisma.ServiceOrderByWithRelationInput
switch (sortProperty) {
case 'verificationRequests':
orderBy = { verificationRequests: { _count: sortDirection } }
break
case 'createdAt': // createdAt can be ambiguous without a specific direction
case 'name':
case 'overallScore':
orderBy = { [sortProperty]: sortDirection }
break
default:
orderBy = { name: 'asc' } // Default sort
}
const whereClause: Prisma.ServiceWhereInput = {
...(filters.search
? {
OR: [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ description: { contains: filters.search, mode: 'insensitive' } },
{ serviceUrls: { has: filters.search } },
{ tosUrls: { has: filters.search } },
{ onionUrls: { has: filters.search } },
{ i2pUrls: { has: filters.search } },
],
}
: {}),
verificationStatus: filters.verificationStatus ?? undefined,
serviceVisibility: filters.visibility ?? undefined,
}
const totalServicesCount = await Astro.locals.banners.try(
'Error counting services',
async () => prisma.service.count({ where: whereClause }),
0
)
const totalPages = Math.ceil(totalServicesCount / itemsPerPage)
const validPage = Math.max(1, Math.min(filters.page, totalPages || 1))
const skip = (validPage - 1) * itemsPerPage
const services = await Astro.locals.banners.try(
'Error fetching services',
async () =>
prisma.service.findMany({
where: whereClause,
select: {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
overallScore: true,
privacyScore: true,
trustScore: true,
verificationStatus: true,
imageUrl: true,
serviceUrls: true,
tosUrls: true,
onionUrls: true,
i2pUrls: true,
serviceVisibility: true,
createdAt: true,
categories: {
select: {
id: true,
name: true,
icon: true,
},
},
attributes: {
include: {
attribute: true,
},
},
_count: {
select: {
verificationRequests: true,
},
},
},
orderBy,
take: itemsPerPage,
skip,
}),
[]
)
const servicesWithInfo = services.map((service) => {
const verificationStatusInfo = getVerificationStatusInfo(service.verificationStatus)
const kycLevelInfo = getKycLevelInfo(String(service.kycLevel))
return {
...service,
verificationStatusInfo,
kycLevelInfo,
kycColor:
service.kycLevel === 0
? '22, 163, 74' // green-600
: service.kycLevel === 1
? '180, 83, 9' // amber-700
: service.kycLevel === 2
? '220, 38, 38' // red-600
: service.kycLevel === 3
? '185, 28, 28' // red-700
: service.kycLevel === 4
? '153, 27, 27' // red-800
: '107, 114, 128', // gray-500 fallback
formattedDate: new Date(service.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
}
})
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort']>) => {
const newOrder = filters.sort === slug && filters.order === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort', slug)
searchParams.set('order', newOrder)
return `/admin/services?${searchParams.toString()}`
}
const getPaginationUrl = (pageNum: number) => {
const url = new URL(Astro.url)
url.searchParams.set('page', pageNum.toString())
return url.toString()
}
const truncate = (text: string, length: number) => {
if (text.length <= length) return text
return text.substring(0, length) + '...'
}
---
<BaseLayout pageTitle="Services Admin" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">Service Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{totalServicesCount} services</span>
<a
href="/admin/services/new"
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
>
<Icon name="ri:add-line" class="size-4" />
<span>New Service</span>
</a>
</div>
</div>
<!-- Search and Filters -->
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
<form method="GET" class="grid gap-3 md:grid-cols-2 lg:grid-cols-5" autocomplete="off">
<div class="lg:col-span-2">
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<div class="mt-1 flex rounded-md shadow-sm">
<input
type="text"
name="search"
id="search"
value={filters.search}
placeholder="Search by name, description, URL..."
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label for="visibility" class="block text-xs font-medium text-zinc-400">Visibility</label>
<select
name="visibility"
id="visibility"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Visibilities</option>
{
Object.values(ServiceVisibility).map((status) => (
<option value={status} selected={filters.visibility === status}>
{status}
</option>
))
}
</select>
</div>
<div>
<label for="verificationStatus" class="block text-xs font-medium text-zinc-400">Status</label>
<select
name="verificationStatus"
id="verificationStatus"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Statuses</option>
{
Object.values(VerificationStatus).map((status) => (
<option value={status} selected={filters.verificationStatus === status}>
{status}
</option>
))
}
</select>
</div>
<div>
<label for="sort" class="block text-xs font-medium text-zinc-400">Sort By</label>
<div class="mt-1 flex rounded-md shadow-sm">
<select
name="sort"
id="sort"
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
{
['name', 'createdAt', 'overallScore', 'verificationRequests'].map((option) => (
<option value={option} selected={filters.sort === option}>
{option}
</option>
))
}
</select>
<button
type="submit"
class="ml-2 inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
</div>
</div>
</form>
</div>
<!-- Services Table -->
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
<h2 class="font-title font-semibold text-blue-400">Services List</h2>
<div class="mt-1 text-xs text-zinc-400 md:hidden">
<span>Scroll horizontally to see more →</span>
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="min-w-[750px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
scope="col"
class="w-[30%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('name')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon active={filters.sort === 'name'} sortOrder={filters.order} />
</a>
</th>
<th
scope="col"
class="w-[8%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>KYC</th
>
<th
scope="col"
class="w-[8%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('overallScore')}
class="flex items-center justify-center hover:text-zinc-200"
>
Score <SortArrowIcon active={filters.sort === 'overallScore'} sortOrder={filters.order} />
</a>
</th>
<th
scope="col"
class="w-[14%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>Status</th
>
<th
scope="col"
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('verificationRequests')}
class="flex items-center justify-center hover:text-zinc-200"
>
<span class="hidden sm:inline">Requests</span>
<span class="sm:hidden">Reqs</span>
<SortArrowIcon active={filters.sort === 'verificationRequests'} sortOrder={filters.order} />
</a>
</th>
<th
scope="col"
class="w-[14%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Date <SortArrowIcon active={filters.sort === 'createdAt'} sortOrder={filters.order} />
</a>
</th>
<th
scope="col"
class="w-[12%] px-4 py-3 text-right text-xs font-medium tracking-wider text-zinc-400 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
{
servicesWithInfo.map((service) => (
<tr id={`service-${service.id}`} class="group hover:bg-zinc-700/30">
<td class="px-4 py-3">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 flex-shrink-0">
{service.imageUrl ? (
<Image
src={service.imageUrl}
alt={service.name}
width={40}
height={40}
class="h-10 w-10 rounded-md object-cover"
/>
) : (
<Image
src={defaultImage}
alt={service.name}
width={40}
height={40}
class="h-10 w-10 rounded-md object-cover"
/>
)}
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-zinc-200">{service.name}</div>
<div class="truncate text-xs text-zinc-400" title={service.description}>
{truncate(service.description, 45)}
</div>
<div class="mt-1 flex flex-wrap gap-1">
{service.categories.slice(0, 2).map((category) => (
<span class="text-2xs inline-flex items-center rounded-sm bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">
<Icon name={category.icon} class="mr-0.5 size-2.5" />
{category.name}
</span>
))}
{service.categories.length > 2 && (
<span class="text-2xs inline-flex items-center rounded-sm bg-zinc-700/60 px-1.5 py-0.5 text-zinc-300">
+{service.categories.length - 2}
</span>
)}
</div>
</div>
</div>
</td>
<td class="px-4 py-3 text-center">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-xs font-bold"
style={`background-color: rgba(${service.kycColor}, 0.3); color: rgb(${service.kycColor})`}
title={service.kycLevelInfo.name}
>
{service.kycLevel}
</span>
</td>
<td class="px-4 py-3 text-center">
<div class="flex flex-col items-center">
<span
class={cn('text-sm font-medium', {
'text-green-400': service.overallScore >= 7,
'text-yellow-400': service.overallScore >= 4 && service.overallScore < 7,
'text-red-400': service.overallScore < 4,
})}
>
{service.overallScore}
</span>
<div class="mt-0.5 grid grid-cols-2 gap-0.5">
<span title="Privacy Score" class="text-2xs font-medium text-blue-400">
{service.privacyScore}
</span>
<span title="Trust Score" class="text-2xs font-medium text-yellow-400">
{service.trustScore}
</span>
</div>
</div>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn('inline-flex items-center rounded-md px-2 py-1 text-xs font-medium', {
'bg-green-500/20 text-green-400':
service.verificationStatus === 'VERIFICATION_SUCCESS',
'bg-red-500/20 text-red-400': service.verificationStatus === 'VERIFICATION_FAILED',
'bg-blue-500/20 text-blue-400': service.verificationStatus === 'APPROVED',
'bg-gray-500/20 text-gray-400':
service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
})}
>
<Icon name={service.verificationStatusInfo.icon} class="mr-1 size-3" />
<span class="hidden sm:inline">{service.verificationStatus}</span>
<span class="sm:hidden">{service.verificationStatus.substring(0, 4)}</span>
</span>
<div class="text-2xs mt-1 font-medium text-zinc-500">
<span
class={cn('text-2xs inline-flex items-center rounded-sm px-1.5 py-0.5', {
'bg-green-900/30 text-green-300': service.serviceVisibility === 'PUBLIC',
'bg-yellow-900/30 text-yellow-300': service.serviceVisibility === 'UNLISTED',
'bg-red-900/30 text-red-300': service.serviceVisibility === 'HIDDEN',
})}
>
{service.serviceVisibility}
</span>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
{service._count.verificationRequests}
</span>
</td>
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end space-x-2">
<a
href={`/service/${service.slug}`}
target="_blank"
class="inline-flex items-center rounded-md border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-700"
title="View on site"
>
<Icon name="ri:external-link-line" class="size-3.5" />
</a>
<a
href={`/admin/services/${service.slug}/edit`}
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
>
Edit
</a>
</div>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
{/* Pagination controls */}
{
totalPages > 1 && (
<div class="mt-6 flex flex-wrap items-center justify-between gap-4">
<div class="text-sm text-zinc-500">
Showing {services.length > 0 ? skip + 1 : 0} to {Math.min(skip + itemsPerPage, totalServicesCount)}{' '}
of {totalServicesCount} services
</div>
<nav class="inline-flex rounded-md shadow-sm" aria-label="Pagination">
{validPage > 1 && (
<a
href={getPaginationUrl(validPage - 1)}
class="inline-flex items-center rounded-l-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
<Icon name="ri:arrow-left-s-line" class="size-5" />
<span class="sr-only">Previous</span>
</a>
)}
{validPage > 3 && (
<a
href={getPaginationUrl(1)}
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
1
</a>
)}
{validPage > 4 && (
<span class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-500">
...
</span>
)}
{validPage > 2 && (
<a
href={getPaginationUrl(validPage - 2)}
class="hidden items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700 md:inline-flex"
>
{validPage - 2}
</a>
)}
{validPage > 1 && (
<a
href={getPaginationUrl(validPage - 1)}
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
{validPage - 1}
</a>
)}
<a
href={getPaginationUrl(validPage)}
class="inline-flex items-center border border-blue-500 bg-blue-500/20 px-3 py-1 text-sm font-medium text-blue-400"
aria-current="page"
>
{validPage}
</a>
{validPage < totalPages && (
<a
href={getPaginationUrl(validPage + 1)}
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
{validPage + 1}
</a>
)}
{validPage < totalPages - 1 && (
<a
href={getPaginationUrl(validPage + 2)}
class="hidden items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700 md:inline-flex"
>
{validPage + 2}
</a>
)}
{validPage < totalPages - 3 && (
<span class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-500">
...
</span>
)}
{validPage < totalPages - 2 && (
<a
href={getPaginationUrl(totalPages)}
class="inline-flex items-center border border-zinc-700 bg-zinc-800 px-3 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
{totalPages}
</a>
)}
{validPage < totalPages && (
<a
href={getPaginationUrl(validPage + 1)}
class="inline-flex items-center rounded-r-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
<Icon name="ri:arrow-right-s-line" class="size-5" />
<span class="sr-only">Next</span>
</a>
)}
</nav>
</div>
)
}
</BaseLayout>
<style>
/* Base CSS text size utility for super small text */
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
/* Scrollbar styling for better mobile experience */
.scrollbar-thin::-webkit-scrollbar {
height: 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;
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
}
</style>

View File

@@ -0,0 +1,366 @@
---
import { AttributeCategory, Currency, VerificationStatus } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { prisma } from '../../../lib/prisma'
const categories = await Astro.locals.banners.try('Failed to fetch categories', () =>
prisma.category.findMany({
orderBy: { name: 'asc' },
})
)
const attributes = await Astro.locals.banners.try('Failed to fetch attributes', () =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
})
)
const result = Astro.getActionResult(actions.admin.service.create)
Astro.locals.banners.addIfSuccess(result, 'Service created successfully')
if (result && !result.error) {
return Astro.redirect(`/admin/services/${result.data.service.slug}/edit`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
---
<BaseLayout pageTitle="Create Service" widthClassName="max-w-screen-sm">
<section class="mb-8">
<div class="font-title mb-4">
<span class="text-sm text-green-500">service.create</span>
</div>
<form
method="POST"
action={actions.admin.service.create}
class="space-y-4 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
enctype="multipart/form-data"
>
<div>
<label for="name" class="font-title mb-2 block text-sm text-green-500">name</label>
<input
transition:persist
type="text"
name="name"
id="name"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.name.join(', ')}</p>
)
}
</div>
<div>
<label for="description" class="font-title mb-2 block text-sm text-green-500">description</label>
<textarea
transition:persist
name="description"
id="description"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea>
{
inputErrors.description && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.description.join(', ')}</p>
)
}
</div>
<div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls"
id="serviceUrls"
rows={3}
placeholder="https://example1.com https://example2.com"></textarea>
{
inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="tosUrls" class="font-title mb-2 block text-sm text-green-500">tosUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="tosUrls"
id="tosUrls"
rows={3}
placeholder="https://example1.com/tos https://example2.com/tos"></textarea>
{
inputErrors.tosUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.tosUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"></textarea>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2">
<input
transition:persist
type="file"
name="imageFile"
id="imageFile"
accept="image/*"
required
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
<p class="font-title text-xs text-gray-400">
Upload a square image for best results. Supported formats: JPG, PNG, WebP, SVG.
</p>
</div>
{
inputErrors.imageFile && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.imageFile.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="categories">categories</label>
<div class="mt-2 grid grid-cols-2 gap-2">
{
categories?.map((category) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="categories"
value={category.id}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
<Icon class="h-3 w-3 text-green-500" name={category.icon} />
{category.name}
</span>
</label>
))
}
</div>
{
inputErrors.categories && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.categories.join(', ')}</p>
)
}
</div>
<div>
<label for="kycLevel" class="font-title mb-2 block text-sm text-green-500">kycLevel</label>
<input
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
type="number"
name="kycLevel"
id="kycLevel"
min={0}
max={4}
value={4}
required
/>
{
inputErrors.kycLevel && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.kycLevel.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="attributes">attributes</label>
<div class="space-y-4">
{
Object.values(AttributeCategory).map((category) => (
<div class="rounded-md border border-green-500/20 bg-black/30 p-4">
<h4 class="font-title mb-3 text-green-400">{category}</h4>
<div class="grid grid-cols-1 gap-2">
{attributes
?.filter((attr) => attr.category === category)
.map((attr) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="attributes"
value={attr.id}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 flex items-center gap-2 text-gray-300">
{attr.title}
<span
class={cn('font-title rounded-sm px-1.5 py-0.5 text-xs', {
'border border-green-500/50 bg-green-500/20 text-green-400':
attr.type === 'GOOD',
'border border-red-500/50 bg-red-500/20 text-red-400': attr.type === 'BAD',
'border border-yellow-500/50 bg-yellow-500/20 text-yellow-400':
attr.type === 'WARNING',
'border border-blue-500/50 bg-blue-500/20 text-blue-400': attr.type === 'INFO',
})}
>
{attr.type}
</span>
</span>
</label>
))}
</div>
{inputErrors.attributes && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.attributes.join(', ')}</p>
)}
</div>
))
}
</div>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationStatus"
>verificationStatus</label
>
<select
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
name="verificationStatus"
id="verificationStatus"
required
>
{Object.values(VerificationStatus).map((status) => <option value={status}>{status}</option>)}
</select>
{
inputErrors.verificationStatus && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationStatus.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationSummary"
>verificationSummary</label
>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationSummary"
id="verificationSummary"
rows={3}></textarea>
{
inputErrors.verificationSummary && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationSummary.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verificationProofMd"
>verificationProofMd</label
>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="verificationProofMd"
id="verificationProofMd"
rows={10}></textarea>
{
inputErrors.verificationProofMd && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.verificationProofMd.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="acceptedCurrencies"
>acceptedCurrencies</label
>
<div class="mt-2 grid grid-cols-2 gap-2">
{
Object.values(Currency).map((currency) => (
<label class="inline-flex items-center">
<input
transition:persist
type="checkbox"
name="acceptedCurrencies"
value={currency}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title ml-2 text-gray-300">{currency}</span>
</label>
))
}
</div>
{
inputErrors.acceptedCurrencies && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.acceptedCurrencies.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="overallScore">overallScore</label>
<input
transition:persist
type="number"
name="overallScore"
id="overallScore"
value={0}
min={0}
max={10}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.overallScore && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.overallScore.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
<input
transition:persist
type="text"
name="referral"
id="referral"
placeholder="Optional referral code/link"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
inputErrors.referral && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.referral.join(', ')}</p>
)
}
</div>
<button
type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Create Service
</button>
</form>
</section>
</BaseLayout>

View File

@@ -0,0 +1,626 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import Tooltip from '../../../components/Tooltip.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { prisma } from '../../../lib/prisma'
import { transformCase } from '../../../lib/strings'
import { timeAgo } from '../../../lib/timeAgo'
const { username } = Astro.params
if (!username) return Astro.rewrite('/404')
const updateResult = Astro.getActionResult(actions.admin.user.update)
Astro.locals.banners.addIfSuccess(updateResult, 'User updated successfully')
if (updateResult && !updateResult.error && username !== updateResult.data.updatedUser.name) {
return Astro.redirect(`/admin/users/${updateResult.data.updatedUser.name}`)
}
const updateInputErrors = isInputError(updateResult?.error) ? updateResult.error.fields : {}
const addAffiliationResult = Astro.getActionResult(actions.admin.user.serviceAffiliations.add)
Astro.locals.banners.addIfSuccess(addAffiliationResult, 'Service affiliation added successfully')
const removeAffiliationResult = Astro.getActionResult(actions.admin.user.serviceAffiliations.remove)
Astro.locals.banners.addIfSuccess(removeAffiliationResult, 'Service affiliation removed successfully')
const [user, allServices] = await Astro.locals.banners.tryMany([
[
'Failed to load user profile',
async () => {
if (!username) return null
return await prisma.user.findUnique({
where: { name: username },
select: {
id: true,
name: true,
displayName: true,
picture: true,
link: true,
admin: true,
verified: true,
verifier: true,
spammer: true,
verifiedLink: true,
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
serviceAffiliations: {
select: {
id: true,
role: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
})
},
null,
],
[
'Failed to load services',
async () => {
return await prisma.service.findMany({
select: {
id: true,
name: true,
},
orderBy: {
name: 'asc',
},
})
},
[],
],
])
if (!user) return Astro.rewrite('/404')
---
<BaseLayout pageTitle={`User: ${user.name}`} htmx>
<div class="container mx-auto max-w-2xl py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="font-title text-2xl text-green-400">User Profile: {user.name}</h1>
<a
href="/admin/users"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:arrow-left-line" class="mr-1 size-4" />
Back to Users
</a>
</div>
<section
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<div class="mb-6 flex items-center gap-4">
{
user.picture ? (
<img src={user.picture} alt="" class="h-16 w-16 rounded-full ring-2 ring-green-500/30" />
) : (
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10 ring-2 ring-green-500/30">
<span class="font-title text-2xl text-green-500">{user.name.charAt(0) || 'A'}</span>
</div>
)
}
<div>
<h2 class="font-title text-lg text-green-400">{user.name}</h2>
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="font-title rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="font-title rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="font-title rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div>
</div>
</div>
<form
method="POST"
action={actions.admin.user.update}
class="space-y-4 border-t border-green-500/30 pt-6"
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={user.id} />
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="name"> Name </label>
<input
transition:persist
type="text"
name="name"
id="name"
value={user.name}
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.name && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.name.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="displayName"> Display Name </label>
<input
transition:persist
type="text"
name="displayName"
maxlength={50}
id="displayName"
value={user.displayName ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
Array.isArray(updateInputErrors.displayName) && updateInputErrors.displayName.length > 0 && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.displayName.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="link"> Link </label>
<input
transition:persist
type="url"
name="link"
id="link"
value={user.link ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.link && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.link.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="picture">
Picture url or path
</label>
<input
transition:persist
type="text"
name="picture"
id="picture"
value={user.picture ?? ''}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.picture && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.picture.join(', ')}</p>
)
}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="pictureFile">
Profile Picture Upload
</label>
<input
transition:persist
type="file"
name="pictureFile"
id="pictureFile"
accept="image/*"
class="font-title file:font-title block w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 file:mr-3 file:rounded-md file:border-0 file:bg-green-500/30 file:px-3 file:py-1 file:text-gray-300 focus:border-green-500 focus:ring-green-500"
/>
<p class="font-title text-xs text-gray-400">
Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF, JXL. Max size:
5MB.
</p>
</div>
<div class="flex gap-6">
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="admin"
checked={user.admin}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-green-500">Admin</span>
</label>
<Tooltip
as="label"
class="flex cursor-not-allowed items-center gap-2"
text="Automatically set based on verified link"
>
<input
type="checkbox"
name="verified"
checked={user.verified}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
disabled
/>
<span class="font-title text-sm text-green-500">Verified</span>
</Tooltip>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="verifier"
checked={user.verifier}
class="rounded-sm border-green-500/30 bg-black/50 text-green-500 focus:ring-green-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-green-500">Verifier</span>
</label>
<label class="flex items-center gap-2">
<input
transition:persist
type="checkbox"
name="spammer"
checked={user.spammer}
class="rounded-sm border-red-500/30 bg-black/50 text-red-500 focus:ring-red-500 focus:ring-offset-black"
/>
<span class="font-title text-sm text-red-500">Spammer</span>
</label>
</div>
{
updateInputErrors.admin && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.admin.join(', ')}</p>
)
}
{
updateInputErrors.verifier && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifier.join(', ')}</p>
)
}
{
updateInputErrors.spammer && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.spammer.join(', ')}</p>
)
}
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="verifiedLink">
Verified Link
</label>
<input
transition:persist
type="url"
name="verifiedLink"
id="verifiedLink"
value={user.verifiedLink}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
updateInputErrors.verifiedLink && (
<p class="font-title mt-1 text-sm text-red-500">{updateInputErrors.verifiedLink.join(', ')}</p>
)
}
</div>
<div class="flex gap-4 pt-4">
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:save-line" class="mr-2 size-4" />
Save
</button>
</div>
</form>
{
Astro.locals.user && user.id !== Astro.locals.user.id && (
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
class="font-title mt-4 inline-flex items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-2 text-sm text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:spy-line" class="mr-2 size-4" />
Impersonate
</a>
)
}
</section>
<section
class="mt-8 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<h2 class="font-title mb-4 text-lg text-green-500">Internal Notes</h2>
{
user.internalNotes.length === 0 ? (
<p class="text-gray-400">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div data-note-id={note.id} class="rounded-lg border border-green-500/30 bg-black/50 p-4">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-title text-xs text-gray-200">
{note.addedByUser ? note.addedByUser.name : 'System'}
</span>
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(note.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
<div class="flex items-center gap-2">
<label class="font-title inline-flex cursor-pointer items-center justify-center rounded-md border border-yellow-500/30 bg-yellow-500/10 px-2 py-1 text-xs text-yellow-400 shadow-xs transition-colors duration-200 hover:bg-yellow-500/20 focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden">
<input
type="checkbox"
class="peer sr-only"
data-edit-note-checkbox
data-note-id={note.id}
/>
<Icon name="ri:edit-line" class="size-3" />
</label>
<form method="POST" action={actions.admin.user.internalNotes.delete} class="inline-flex">
<input type="hidden" name="noteId" value={note.id} />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-3" />
</button>
</form>
</div>
</div>
<div data-note-content>
<p class="font-title text-sm whitespace-pre-wrap text-gray-300">{note.content}</p>
</div>
<form
method="POST"
action={actions.admin.user.internalNotes.update}
data-note-edit-form
class="mt-2 hidden"
>
<input type="hidden" name="noteId" value={note.id} />
<textarea
name="content"
rows="3"
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
data-trim-content
>
{note.content}
</textarea>
<div class="mt-2 flex justify-end gap-2">
<button
type="button"
data-cancel-edit
class="font-title inline-flex items-center justify-center rounded-md border border-gray-500/30 bg-gray-500/10 px-3 py-1 text-xs text-gray-400 shadow-xs transition-colors duration-200 hover:bg-gray-500/20 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Cancel
</button>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-3 py-1 text-xs text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
Save
</button>
</div>
</form>
</div>
))}
</div>
)
}
<form method="POST" action={actions.admin.user.internalNotes.add} class="mt-4 space-y-2">
<input type="hidden" name="userId" value={user.id} />
<textarea
name="content"
placeholder="Add a note..."
rows="3"
class="font-title min-h-12 w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
></textarea>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:add-line" class="mr-1 size-4" />
Add
</button>
</form>
</section>
<section
class="mt-8 rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<h2 class="font-title mb-4 text-lg text-green-500">Service Affiliations</h2>
{
user.serviceAffiliations.length === 0 ? (
<p class="text-gray-400">No service affiliations yet.</p>
) : (
<div class="space-y-4">
{user.serviceAffiliations.map((affiliation) => (
<div class="flex items-center justify-between rounded-lg border border-green-500/30 bg-black/50 p-4">
<div>
<div class="flex items-center gap-2">
<a
href={`/service/${affiliation.service.slug}`}
class="font-title text-sm text-green-400 hover:underline"
>
{affiliation.service.name}
</a>
<span
class={`font-title rounded-full px-2 py-0.5 text-xs ${
affiliation.role === 'OWNER'
? 'border border-purple-500/50 bg-purple-500/20 text-purple-400'
: affiliation.role === 'ADMIN'
? 'border border-red-500/50 bg-red-500/20 text-red-400'
: affiliation.role === 'MODERATOR'
? 'border border-orange-500/50 bg-orange-500/20 text-orange-400'
: affiliation.role === 'SUPPORT'
? 'border border-blue-500/50 bg-blue-500/20 text-blue-400'
: 'border border-green-500/50 bg-green-500/20 text-green-400'
}`}
>
{affiliation.role.toLowerCase()}
</span>
</div>
<div class="mt-1 flex items-center gap-2">
<span class="font-title text-xs text-gray-500">
{transformCase(timeAgo.format(affiliation.createdAt, 'twitter-minute-now'), 'sentence')}
</span>
</div>
</div>
<form
method="POST"
action={actions.admin.user.serviceAffiliations.remove}
class="inline-flex"
data-astro-reload
>
<input type="hidden" name="id" value={affiliation.id} />
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-3" />
</button>
</form>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.user.serviceAffiliations.add}
class="mt-6 space-y-4 border-t border-green-500/30 pt-6"
data-astro-reload
>
<input type="hidden" name="userId" value={user.id} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="serviceId"> Service </label>
<select
name="serviceId"
id="serviceId"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
>
<option value="">Select a service</option>
{allServices.map((service) => <option value={service.id}>{service.name}</option>)}
</select>
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="role"> Role </label>
<select
name="role"
id="role"
required
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 text-gray-300 focus:border-green-500 focus:ring-green-500"
>
<option value="OWNER">Owner</option>
<option value="ADMIN">Admin</option>
<option value="MODERATOR">Moderator</option>
<option value="SUPPORT">Support</option>
<option value="TEAM_MEMBER">Team Member</option>
</select>
</div>
</div>
<div>
<button
type="submit"
class="font-title inline-flex items-center justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:link" class="mr-1 size-4" />
Add Service Affiliation
</button>
</div>
</form>
</section>
</div>
</BaseLayout>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLDivElement>('[data-note-id]').forEach((noteDiv) => {
const checkbox = noteDiv.querySelector<HTMLInputElement>('input[data-edit-note-checkbox]')
if (!checkbox) return
checkbox.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement
if (!target) return
const noteContent = noteDiv.querySelector<HTMLDivElement>('[data-note-content]')
const editForm = noteDiv.querySelector<HTMLFormElement>('[data-note-edit-form]')
const cancelButton = noteDiv.querySelector<HTMLButtonElement>('[data-cancel-edit]')
if (noteContent && editForm) {
if (target.checked) {
noteContent.classList.add('hidden')
editForm.classList.remove('hidden')
} else {
noteContent.classList.remove('hidden')
editForm.classList.add('hidden')
}
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
target.checked = false
noteContent?.classList.remove('hidden')
editForm?.classList.add('hidden')
})
}
})
})
})
</script>
<script>
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLTextAreaElement>('[data-trim-content]').forEach((textarea) => {
textarea.value = textarea.value.trim()
})
})
</script>

View File

@@ -0,0 +1,377 @@
---
import { Icon } from 'astro-icon/components'
import { z } from 'astro:content'
import { orderBy as lodashOrderBy } from 'lodash-es'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import Tooltip from '../../../components/Tooltip.astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { pluralize } from '../../../lib/pluralize'
import { prisma } from '../../../lib/prisma'
import { formatDateShort } from '../../../lib/timeAgo'
import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
role: z.enum(['user', 'admin', 'verifier', 'verified', 'spammer']).optional(),
},
Astro
)
// Set up Prisma orderBy with correct typing
const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
}
: { createdAt: 'desc' as const }
// Build where clause based on role filter
const whereClause: Prisma.UserWhereInput = {}
if (filters.search) {
whereClause.OR = [{ name: { contains: filters.search, mode: 'insensitive' } }]
}
if (filters.role) {
switch (filters.role) {
case 'user': {
whereClause.admin = false
whereClause.verifier = false
whereClause.verified = false
whereClause.spammer = false
break
}
case 'admin': {
whereClause.admin = true
break
}
case 'verifier': {
whereClause.verifier = true
break
}
case 'verified': {
whereClause.verified = true
break
}
case 'spammer': {
whereClause.spammer = true
break
}
}
}
// Retrieve users from the database
const dbUsers = await prisma.user.findMany({
where: whereClause,
select: {
id: true,
name: true,
verified: true,
admin: true,
verifier: true,
spammer: true,
totalKarma: true,
createdAt: true,
updatedAt: true,
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
},
},
_count: {
select: {
suggestions: true,
comments: true,
},
},
},
orderBy: prismaOrderBy,
})
const users =
filters['sort-by'] === 'role'
? lodashOrderBy(dbUsers, [(u) => (u.admin ? 'admin' : 'user')], [filters['sort-order']])
: dbUsers
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const currentSortBy = filters['sort-by']
const currentSortOrder = filters['sort-order']
const newSortOrder = currentSortBy === slug && currentSortOrder === 'asc' ? 'desc' : 'asc'
const searchParams = new URLSearchParams(Astro.url.search)
searchParams.set('sort-by', slug)
searchParams.set('sort-order', newSortOrder)
return `/admin/users?${searchParams.toString()}`
}
---
<BaseLayout pageTitle="User Management" widthClassName="max-w-screen-xl">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-title text-2xl font-bold text-white">User Management</h1>
<div class="mt-2 flex items-center gap-4 sm:mt-0">
<span class="text-sm text-zinc-400">{users.length} users</span>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-700 bg-zinc-800/50 p-4 shadow-lg">
<form method="GET" class="grid gap-3 md:grid-cols-2" autocomplete="off">
<div>
<label for="search" class="block text-xs font-medium text-zinc-400">Search</label>
<input
type="text"
name="search"
id="search"
value={filters.search}
placeholder="Search by name..."
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="role-filter" class="block text-xs font-medium text-zinc-400">Filter by Role/Status</label>
<div class="mt-1 flex">
<select
name="role"
id="role-filter"
class="w-full rounded-l-md border border-r-0 border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="" selected={!filters.role}>All Users</option>
<option value="user" selected={filters.role === 'user'}>Regular Users</option>
<option value="admin" selected={filters.role === 'admin'}>Admins</option>
<option value="verifier" selected={filters.role === 'verifier'}>Verifiers</option>
<option value="verified" selected={filters.role === 'verified'}>Verified Users</option>
<option value="spammer" selected={filters.role === 'spammer'}>Spammers</option>
</select>
<button
type="submit"
class="inline-flex items-center rounded-r-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900 focus:outline-none"
>
<Icon name="ri:search-2-line" class="h-4 w-4" />
</button>
</div>
</div>
</form>
</div>
<div class="rounded-lg border border-zinc-700 bg-zinc-800/50 shadow-lg">
<div class="sticky top-0 z-10 border-b border-zinc-700 bg-zinc-800/90 px-4 py-3 backdrop-blur-sm">
<h2 class="font-title font-semibold text-blue-400">Users List</h2>
<div class="mt-1 text-xs text-zinc-400 md:hidden">
<span>Scroll horizontally to see more →</span>
</div>
</div>
<div class="scrollbar-thin max-w-full overflow-x-auto">
<div class="min-w-[900px]">
<table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30">
<tr>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('name')} class="flex items-center hover:text-zinc-200">
Name <SortArrowIcon
active={filters['sort-by'] === 'name'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Status
</th>
<th
class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('role')} class="flex items-center hover:text-zinc-200">
Role <SortArrowIcon
active={filters['sort-by'] === 'role'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[10%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('karma')} class="flex items-center justify-center hover:text-zinc-200">
Karma <SortArrowIcon
active={filters['sort-by'] === 'karma'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Activity
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700 bg-zinc-800/10">
{
users.map((user) => (
<tr
id={`user-${user.id}`}
class={`group hover:bg-zinc-700/30 ${user.spammer ? 'bg-red-900/10' : ''}`}
>
<td class="px-4 py-3 text-sm font-medium text-zinc-200">
<div>{user.name}</div>
{user.internalNotes.length > 0 && (
<Tooltip
class="text-2xs mt-1 text-yellow-400"
position="right"
text={user.internalNotes
.map(
(note) =>
`${formatDateShort(note.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})}: ${note.content}`
)
.join('\n\n')}
>
<Icon name="ri:sticky-note-line" class="mr-1 inline-block size-3" />
{user.internalNotes.length} internal {pluralize('note', user.internalNotes.length)}
</Tooltip>
)}
</td>
<td class="px-4 py-3 text-sm">
<div class="flex flex-col items-center gap-1.5">
{user.spammer && (
<span class="inline-flex items-center gap-1 rounded-md bg-red-900/30 px-2 py-0.5 text-xs font-medium text-red-400">
<Icon name="ri:spam-2-fill" class="size-3.5" />
Spammer
</span>
)}
{user.verified && (
<span class="inline-flex items-center gap-1 rounded-md bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-400">
<Icon name="ri:checkbox-circle-fill" class="size-3.5" />
Verified
</span>
)}
{user.verifier && (
<span class="inline-flex items-center gap-1 rounded-md bg-blue-900/30 px-2 py-0.5 text-xs font-medium text-blue-400">
<Icon name="ri:shield-check-fill" class="size-3.5" />
Verifier
</span>
)}
</div>
</td>
<td class="px-4 py-3 text-sm">
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${user.admin ? 'bg-purple-900/30 text-purple-300' : 'bg-zinc-700 text-zinc-300'}`}
>
{user.admin ? 'Admin' : 'User'}
</span>
</td>
<td class="px-4 py-3 text-center text-sm">
<span
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
>
{user.totalKarma}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-zinc-400">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-3">
<div class="flex flex-col items-center" title="Suggestions">
<span class="text-2xs text-zinc-400">Suggestions</span>
<span class="inline-flex items-center rounded-full bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-300">
{user._count.suggestions}
</span>
</div>
<div class="flex flex-col items-center" title="Comments">
<span class="text-2xs text-zinc-400">Comments</span>
<span class="inline-flex items-center rounded-full bg-purple-900/30 px-2.5 py-0.5 text-xs font-medium text-purple-300">
{user._count.comments}
</span>
</div>
</div>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-2">
<Tooltip
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
class="inline-flex items-center rounded-md border border-orange-500/50 bg-orange-500/20 px-1 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/30"
text="Impersonate"
>
<Icon name="ri:spy-line" class="size-4" />
</Tooltip>
<Tooltip
as="a"
href={`/admin/users/${user.name}`}
class="inline-flex items-center rounded-md border border-blue-500/50 bg-blue-500/20 px-1 py-1 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
text="Edit"
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
</div>
</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
</div>
</BaseLayout>
<style>
.text-2xs {
font-size: 0.6875rem;
line-height: 1rem;
}
.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);
-webkit-overflow-scrolling: touch;
}
}
</style>

View File

@@ -0,0 +1,403 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Picture } from 'astro:assets'
import { z } from 'astro:content'
import { orderBy } from 'lodash-es'
import BadgeStandard from '../components/BadgeStandard.astro'
import { makeOverallScoreInfo } from '../components/ScoreSquare.astro'
import SortArrowIcon from '../components/SortArrowIcon.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { sortAttributes } from '../lib/attributes'
import { cn } from '../lib/cn'
import { formatNumber } from '../lib/numbers'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'category', 'type', 'privacy', 'trust']),
'sort-order': z.enum(['asc', 'desc']).default('asc'),
},
Astro
)
const attributes = await Astro.locals.banners.try(
'Error fetching attributes',
async () =>
prisma.attribute.findMany({
select: {
id: true,
slug: true,
title: true,
description: true,
category: true,
type: true,
privacyPoints: true,
trustPoints: true,
services: {
select: {
service: {
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
overallScore: true,
verificationStatus: true,
},
},
},
},
},
}),
[]
)
const sortBy = filters['sort-by']
const sortedAttributes = sortBy
? orderBy(
sortAttributes(attributes),
sortBy === 'type'
? (attribute) => getAttributeTypeInfo(attribute.type).order
: sortBy === 'category'
? (attribute) => getAttributeCategoryInfo(attribute.category).order
: sortBy === 'name'
? 'title'
: sortBy === 'privacy'
? 'privacyPoints'
: 'trustPoints',
filters['sort-order']
)
: sortAttributes(attributes)
const attributesWithInfo = sortedAttributes.map((attribute) => ({
...attribute,
categoryInfo: getAttributeCategoryInfo(attribute.category),
typeInfo: getAttributeTypeInfo(attribute.type),
services: orderBy(
attribute.services.map(({ service }) => ({
...service,
verificationStatusInfo: getVerificationStatusInfo(service.verificationStatus),
overallScoreInfo: makeOverallScoreInfo(service.overallScore),
})),
[
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : -1),
'overallScore',
() => Math.random(),
],
['asc', 'desc', 'asc']
),
}))
const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
const sortOrder = filters['sort-by'] === slug ? (filters['sort-order'] === 'asc' ? 'desc' : 'asc') : 'asc'
return `/attributes?sort-by=${slug}&sort-order=${sortOrder}`
}
---
<BaseLayout
pageTitle="Attributes"
description="Browse all available service attributes used to evaluate privacy and trust scores on KYCnot.me."
ogImage={{ template: 'generic', title: 'All attributes' }}
>
<h1 class="font-title mb-2 text-center text-3xl font-bold text-white">Service attributes</h1>
<p class="text-center text-balance text-zinc-300">
Characteristics or features of services that affect their scores.
</p>
<p class="mb-8 text-center">
<a
href="/about#service-attributes"
class="mt-2 inline-flex items-center text-sm text-zinc-400 hover:text-zinc-200"
>
<Icon name="ri:information-line" class="mr-1 size-4" />
Learn more about attributes
</a>
</p>
<!-- Mobile view -->
<div class="grid grid-cols-1 gap-4 md:hidden">
{
attributesWithInfo.map((attribute) => (
<div class="space-y-2 rounded-lg border border-zinc-600 bg-zinc-800 p-4">
<div class="flex flex-col items-center space-y-2">
<h3 class={cn('text-center text-lg font-bold', attribute.typeInfo.classNames.text)}>
{attribute.title}
</h3>
<div class="flex space-x-2">
<BadgeStandard
text={attribute.categoryInfo.label}
icon={attribute.categoryInfo.icon}
class={attribute.categoryInfo.classNames.icon}
/>
<BadgeStandard
text={attribute.typeInfo.label}
icon={attribute.typeInfo.icon}
class={attribute.typeInfo.classNames.icon}
/>
</div>
</div>
<div
class={cn(
'prose prose-sm prose-invert mx-auto max-w-lg text-sm',
attribute.typeInfo.classNames.text
)}
>
<Markdown content={attribute.description} />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col items-center text-zinc-200">
<span
class={cn('text-base', attribute.typeInfo.classNames.textLight, {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'opacity-50': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</span>
<span
class={cn('text-2xs font-bold text-white uppercase', attribute.typeInfo.classNames.textLight)}
>
Privacy
</span>
</div>
<div class="flex flex-col items-center text-zinc-200">
<span
class={cn('text-base', attribute.typeInfo.classNames.textLight, {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'opacity-50': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</span>
<span
class={cn('text-2xs font-bold text-white uppercase', attribute.typeInfo.classNames.textLight)}
>
Trust
</span>
</div>
</div>
{attribute.services.length > 0 && (
<details class="pt-2">
<summary class="flex cursor-pointer items-center text-sm text-zinc-300">
Show services
<Icon name="ri:arrow-down-s-line" class="ml-1 size-4" />
</summary>
<ul class="mt-2 grid max-h-64 grid-cols-1 overflow-y-auto rounded bg-zinc-700">
{attribute.services.map((service) => (
<li>
<a
href={`/service/${service.slug}`}
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
>
{service.imageUrl ? (
<Picture
src={service.imageUrl}
alt={service.name}
width={24}
height={24}
formats={['jxl', 'avif', 'webp']}
class="size-6 shrink-0 rounded-xs object-contain"
/>
) : (
<div class="flex size-6 shrink-0 items-center justify-center rounded-xs bg-zinc-800 text-zinc-500">
<Icon name="ri:image-line" class="size-4" />
</div>
)}
<div
class={cn(
'mt-1 flex-1 truncate text-xs text-zinc-300',
service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{service.name}
</div>
{service.verificationStatus !== 'APPROVED' && (
<Icon
name={service.verificationStatusInfo.icon}
class={cn(
'inline-block size-4 shrink-0',
service.verificationStatusInfo.classNames.icon
)}
/>
)}
<span
class={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-xs text-xs font-bold',
service.overallScoreInfo.classNameBg
)}
>
{service.overallScoreInfo.formattedScore}
</span>
</a>
</li>
))}
</ul>
</details>
)}
</div>
))
}
</div>
<!-- Desktop view -->
<div
class="-m-2 hidden grid-cols-[minmax(auto,calc(var(--spacing)*64))_auto_auto_1fr_auto_auto_auto] gap-x-4 md:grid"
>
<div class="col-span-full grid grid-cols-subgrid p-2">
<a href={makeSortUrl('name')}>
Name <SortArrowIcon active={filters['sort-by'] === 'name'} sortOrder={filters['sort-order']} />
</a>
<a href={makeSortUrl('category')}>
Category <SortArrowIcon
active={filters['sort-by'] === 'category'}
sortOrder={filters['sort-order']}
/>
</a>
<a href={makeSortUrl('type')}>
Type <SortArrowIcon active={filters['sort-by'] === 'type'} sortOrder={filters['sort-order']} />
</a>
<div>Description</div>
<a href={makeSortUrl('privacy')}>
Privacy <SortArrowIcon active={filters['sort-by'] === 'privacy'} sortOrder={filters['sort-order']} />
</a>
<a href={makeSortUrl('trust')}>
Trust <SortArrowIcon active={filters['sort-by'] === 'trust'} sortOrder={filters['sort-order']} />
</a>
<div></div>
</div>
{
attributesWithInfo.map((attribute) => (
<div class="group col-span-full grid grid-cols-subgrid hover:bg-zinc-800">
<input type="checkbox" id={`show-services-${attribute.id}`} class="peer/show-services hidden" />
<label
for={`show-services-${attribute.id}`}
class="col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180"
aria-label={`Show services for ${attribute.title}`}
>
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
<BadgeStandard
text={attribute.categoryInfo.label}
icon={attribute.categoryInfo.icon}
class={attribute.categoryInfo.classNames.icon}
/>
<BadgeStandard
text={attribute.typeInfo.label}
icon={attribute.typeInfo.icon}
class={attribute.typeInfo.classNames.icon}
/>
<div
class={cn(
'prose prose-sm prose-invert text-sm text-pretty',
attribute.typeInfo.classNames.text
)}
>
<Markdown content={attribute.description} />
</div>
<div
class={cn('text-center text-base', attribute.typeInfo.classNames.text, {
'text-red-400': attribute.privacyPoints < 0,
'text-green-400': attribute.privacyPoints > 0,
'text-zinc-400': attribute.privacyPoints === 0,
})}
>
{formatNumber(attribute.privacyPoints, { showSign: true })}
</div>
<div
class={cn('text-center text-base', attribute.typeInfo.classNames.text, {
'text-red-400': attribute.trustPoints < 0,
'text-green-400': attribute.trustPoints > 0,
'text-zinc-400': attribute.trustPoints === 0,
})}
>
{formatNumber(attribute.trustPoints, { showSign: true })}
</div>
<div class="flex items-center justify-center">
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
</div>
</label>
{attribute.services.length > 0 && (
<div class="col-span-full hidden rounded bg-zinc-700/80 peer-checked/show-services:block">
<ul class="grid max-h-64 grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*64),1fr))] gap-x-4 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] px-3 py-3">
{attribute.services.map((service) => (
<li>
<a
href={`/service/${service.slug}`}
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-zinc-800"
>
{service.imageUrl ? (
<Picture
src={service.imageUrl}
alt={service.name}
width={24}
height={24}
formats={['jxl', 'avif', 'webp']}
class="size-6 shrink-0 rounded-xs object-contain"
/>
) : (
<div class="flex size-6 shrink-0 items-center justify-center rounded-xs bg-zinc-800 text-zinc-500">
<Icon name="ri:image-line" class="size-4" />
</div>
)}
<div
class={cn(
'mt-1 flex-1 truncate text-xs text-zinc-300',
service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{service.name}
</div>
{service.verificationStatus !== 'APPROVED' && (
<Icon
name={service.verificationStatusInfo.icon}
class={cn(
'inline-block size-4 shrink-0',
service.verificationStatusInfo.classNames.icon
)}
/>
)}
<span
class={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-xs text-xs font-bold',
service.overallScoreInfo.classNameBg
)}
>
{service.overallScoreInfo.formattedScore}
</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
))
}
</div>
</BaseLayout>

474
web/src/pages/events.astro Normal file
View File

@@ -0,0 +1,474 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { Picture } from 'astro:assets'
import { orderBy } from 'lodash-es'
import Button from '../components/Button.astro'
import FormatTimeInterval from '../components/FormatTimeInterval.astro'
import TimeFormatted from '../components/TimeFormatted.astro'
import {
eventTypes,
eventTypesZodEnumBySlug,
getEventTypeInfo,
getEventTypeInfoBySlug,
} from '../constants/eventTypes'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { formatDateShort } from '../lib/timeAgo'
import { createPageUrl } from '../lib/urls'
import type { Prisma } from '@prisma/client'
const PAGE_SIZE = 100
const { data: params, hasDefaultData: hasDefaultFilters } = zodParseQueryParamsStoringErrors(
{
page: z.coerce.number().int().min(1).default(1),
now: z.coerce.date().default(new Date()),
from: z.preprocess((val) => (val === '' ? undefined : val), z.coerce.date().optional()),
to: z.preprocess((val) => (val === '' ? undefined : val), z.coerce.date().optional()),
/** Service's slug */
service: z.string().optional(),
type: eventTypesZodEnumBySlug.optional(),
},
Astro
)
const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
[
'Error fetching services',
async () =>
prisma.service.findMany({
where: {
events: {
some: {
visible: true,
},
},
},
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
verificationStatus: true,
},
orderBy: {
name: 'asc',
},
}),
[],
],
[
'Error fetching events',
async () =>
prisma.event.findManyAndCount({
where: {
visible: true,
createdAt: {
lte: params.now,
},
...(params.service ? { service: { slug: params.service } } : {}),
...(params.type ? { type: getEventTypeInfoBySlug(params.type).id } : {}),
...(params.from || params.to
? {
OR: [
...(params.from
? ([
{ endedAt: null },
{ endedAt: { gte: params.from } },
] satisfies Prisma.EventWhereInput[])
: []),
...(params.to
? ([{ startedAt: { lte: params.to } }] satisfies Prisma.EventWhereInput[])
: []),
],
}
: {}),
},
select: {
id: true,
title: true,
content: true,
source: true,
type: true,
startedAt: true,
endedAt: true,
service: {
select: {
id: true,
slug: true,
name: true,
imageUrl: true,
verificationStatus: true,
},
},
},
orderBy: {
startedAt: 'desc',
},
skip: (params.page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
[[], 0] as const,
],
])
const events = orderBy(
dbEvents.map((event) => ({
...event,
actualEndedAt: event.endedAt ?? params.now,
typeInfo: getEventTypeInfo(event.type),
service: {
...event.service,
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
},
})),
['actualEndedAt', 'startedAt'],
'desc'
)
const totalPages = Math.ceil(totalEvents / PAGE_SIZE) || 1
const hasMorePages = params.page < totalPages
const createUrlWithoutFilter = (paramName: keyof typeof params) => {
const url = new URL(Astro.url)
url.searchParams.delete(paramName)
url.searchParams.forEach((value, key) => {
if (value === '') {
url.searchParams.delete(key)
}
})
return url.toString()
}
---
<BaseLayout
pageTitle="Events"
description="Discover important events, updates, and news about KYC-free services in chronological order."
widthClassName="max-w-screen-lg"
className={{ main: 'sm:flex sm:items-start sm:gap-6' }}
ogImage={{ template: 'generic', title: 'Events' }}
htmx
>
<h1 class="font-title mb-6 block text-center text-2xl font-bold text-white sm:hidden">
Service Events Timeline
</h1>
<form
method="GET"
class={cn(
'mx-auto max-w-xs rounded-lg border border-zinc-700 bg-zinc-800/30 p-4 sm:sticky sm:top-20',
'[&:has(~[data-has-default-filters="true"])_[data-clear-filters-button]]:hidden'
)}
hx-get={Astro.url.pathname}
hx-trigger="input from:input, keyup[key=='Enter'], change from:select"
hx-target="#events-list-container"
hx-select="#events-list-container"
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#search-indicator"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-title text-xl text-green-500">
FILTERS
<Icon
id="search-indicator"
name="ri:loader-2-line"
class="htmx-request:inline-block hidden size-5 animate-spin align-[-0.1em] text-green-500"
/>
</h2>
<a href="/events" class="text-sm text-green-500 hover:text-green-400" data-clear-filters-button>
Clear all
</a>
</div>
<label for="from" class="mt-2 mb-0.5 block text-sm text-zinc-300">From</label>
<input
type="date"
id="from"
name="from"
value={params.from?.toISOString().split('T')[0]}
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
/>
<label for="to" class="mt-2 mb-0.5 block text-sm text-zinc-300">To</label>
<input
type="date"
id="to"
name="to"
value={params.to?.toISOString().split('T')[0]}
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
/>
<label for="type" class="mt-2 mb-0.5 block text-sm text-zinc-300">Type</label>
<select
id="type"
name="type"
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
>
<option value="">All Types</option>
{
eventTypes.map((type) => (
<option value={type.slug} selected={params.type === type.slug}>
{type.label}
</option>
))
}
</select>
<label for="service" class="mt-2 mb-0.5 block text-sm text-zinc-300">Service</label>
<select
id="service"
name="service"
class="w-full rounded-sm border border-green-500/30 bg-black/40 p-2 text-white focus:border-green-500 focus:outline-hidden"
>
<option value="">All Services</option>
{
services.map((service) => (
<option value={service.slug} selected={params.service === service.slug}>
{service.name}
</option>
))
}
</select>
<Button type="submit" label="Apply" size="lg" class="sm:js:hidden mt-6 w-full" color="success" shadow />
</form>
<div class="flex-1" id="events-list-container" data-has-default-filters={hasDefaultFilters}>
<h1 class="font-title mb-3 hidden text-2xl font-bold text-white sm:block">Service Events Timeline</h1>
<!-- Active filters -->
{
!hasDefaultFilters && (
<div class="mt-8 mb-6 flex flex-wrap justify-center gap-2 sm:mt-0 sm:justify-start">
{params.from && (
<a
href={createUrlWithoutFilter('from')}
class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white"
>
<span>
From <TimeFormatted date={params.from} prefix={false} />
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
{params.to && (
<a
href={createUrlWithoutFilter('to')}
class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white"
>
<span>
To <TimeFormatted date={params.to} prefix={false} />
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
{params.service &&
(() => {
const service = services.find((s) => s.slug === params.service)
const verificationStatusInfo = service
? getVerificationStatusInfo(service.verificationStatus)
: null
return (
<a
href={createUrlWithoutFilter('service')}
class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white"
>
{service?.imageUrl && (
<Picture
src={service.imageUrl}
alt={service.name}
width={16}
height={16}
formats={['jxl', 'avif', 'webp']}
class="size-4 shrink-0 rounded-xs object-contain"
/>
)}
<span>
{service?.name ?? params.service}
{service && service.verificationStatus !== 'APPROVED' && verificationStatusInfo && (
<Icon
name={verificationStatusInfo.icon}
class={cn(
'inline-block size-3 shrink-0 align-[-0.1em]',
verificationStatusInfo.classNames.icon
)}
/>
)}
</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)
})()}
{params.type && (
<a
href={createUrlWithoutFilter('type')}
class="group flex h-8 items-center gap-2 rounded-full border border-green-500/30 bg-black/40 px-3 text-sm text-white"
>
<Icon
name={getEventTypeInfo(params.type).icon}
class={cn('size-4', getEventTypeInfo(params.type).classNames.dot, 'bg-transparent')}
/>
<span>{getEventTypeInfo(params.type).label}</span>
<div class="text-gray-400 group-hover:text-white">
<Icon name="ri:close-large-line" class="size-4" />
</div>
</a>
)}
</div>
)
}
{
events.length > 0 ? (
<ol id="events-list" class="mx-auto mt-12 sm:mt-0">
{events.map((event, i) => (
<li
class="flex items-stretch gap-2"
data-hx-event-item
{...(i === events.length - 1 && hasMorePages
? {
'hx-get': createPageUrl(params.page + 1, Astro.url, {
...Object.fromEntries(Astro.url.searchParams.entries()),
now: params.now.toISOString(),
}),
'hx-trigger': 'revealed',
'hx-swap': 'afterend',
'hx-select': '[data-hx-event-item]',
'hx-indicator': '#infinite-scroll-indicator',
}
: {})}
>
<div class="flex flex-col items-center">
<div
class={cn(
'z-10 flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-700 ring-3 ring-zinc-700/50',
event.typeInfo.classNames.dot
)}
>
<Icon name={event.typeInfo.icon} class="size-3" />
</div>
<div class="w-0.5 flex-1 bg-zinc-600" />
</div>
<div class="xs:pb-12 -mt-0.5 flex-1 pb-8">
<h3 class="font-title min-h-5 flex-1 text-lg leading-tight font-semibold text-pretty text-white">
{event.title}
</h3>
<FormatTimeInterval
as="p"
start={event.startedAt}
end={event.endedAt}
now={params.now}
class="mt-2 block text-sm leading-none font-normal text-balance text-zinc-300"
/>
<div class="mt-3 flex items-center gap-4">
<a
href={`/service/${event.service.slug}`}
class="-m-1.5 flex w-fit items-center rounded-md p-1.5 leading-none transition-colors hover:bg-zinc-800"
>
{event.service.imageUrl && (
<Picture
src={event.service.imageUrl}
alt={event.service.name}
width={16}
height={16}
formats={['jxl', 'avif', 'webp']}
class="size-4 shrink-0 rounded-xs object-contain"
/>
)}
<span
class={cn(
'ms-2 text-sm leading-none text-zinc-300',
event.service.verificationStatus === 'VERIFICATION_FAILED' && 'text-red-200'
)}
>
{event.service.name}
</span>
{event.service.verificationStatus !== 'APPROVED' && (
<Icon
name={event.service.verificationStatusInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.verificationStatusInfo.classNames.icon
)}
/>
)}
</a>
{event.source && (
<a
href={event.source}
target="_blank"
rel="noopener noreferrer"
class="inline-block text-xs text-blue-400 hover:underline"
>
Source
<Icon name="ri:external-link-line" class="inline-block size-3 align-[-0.1em]" />
</a>
)}
</div>
<p class="mt-4 text-base font-normal text-pretty text-zinc-400">{event.content}</p>
</div>
</li>
))}
{!hasMorePages && (
<div class="flex min-h-8 items-center gap-2" data-hx-event-item>
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-zinc-600" />
<div class="size-2 flex-shrink-0 rounded-full bg-zinc-600" />
<div class="flex-1" />
</div>
<p class="flex-1 text-xs text-zinc-400 italic">
{params.from
? formatDateShort(params.from, { prefix: false, caseType: 'sentence' })
: events.length > 10
? 'Big Bang'
: null}
</p>
</div>
)}
<div
class="no-js:hidden htmx-request:flex hidden min-h-8 items-center gap-2"
id="infinite-scroll-indicator"
>
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-gradient-to-b from-zinc-600 to-transparent" />
</div>
<p class="flex-1 animate-pulse text-xs text-zinc-400 italic">Loading more events...</p>
</div>
<div class="no-js:flex hidden min-h-8 items-center gap-2" id="infinite-scroll-indicator">
<div class="flex w-5 flex-shrink-0 flex-col items-center self-stretch">
<div class="w-0.5 flex-1 bg-gradient-to-b from-zinc-600 to-transparent" />
</div>
<p class="flex-1 text-xs text-zinc-400 italic">Enable JavaScript to load more events</p>
</div>
</ol>
) : (
<p class="my-12 text-center text-zinc-400">No events reported</p>
)
}
</div>
</BaseLayout>

View File

@@ -0,0 +1,44 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { UPLOAD_DIR } from 'astro:env/server'
import { lookup } from 'mime-types'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ params }) => {
// Get the file path from the URL
const filePath = params.path
if (!filePath) {
return new Response('File not found', { status: 404 })
}
// Get the base upload directory from environment variable
const uploadPath = path.isAbsolute(UPLOAD_DIR) ? UPLOAD_DIR : path.join(process.cwd(), UPLOAD_DIR)
// Full path to the requested file
const fullPath = path.join(uploadPath, filePath)
try {
// Check if file exists
await fs.access(fullPath)
// Read file
const file = await fs.readFile(fullPath)
// Determine content type based on file extension using mime-types library
const contentType = lookup(fullPath) || 'application/octet-stream'
// Return the file with proper content type
return new Response(file, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
},
})
} catch (error) {
console.error('Error serving file:', error)
return new Response('File not found', { status: 404 })
}
}

700
web/src/pages/index.astro Normal file
View File

@@ -0,0 +1,700 @@
---
import { ServiceVisibility } from '@prisma/client'
import { z } from 'astro:schema'
import { groupBy, orderBy } from 'lodash-es'
import seedrandom from 'seedrandom'
import Button from '../components/Button.astro'
import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro'
import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import {
currencies,
currenciesZodEnumBySlug,
currencySlugToId,
getCurrencyInfo,
} from '../constants/currencies'
import { getNetworkInfo, networks } from '../constants/networks'
import {
getVerificationStatusInfo,
verificationStatuses,
verificationStatusesZodEnumBySlug,
verificationStatusSlugToId,
} from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { parseIntWithFallback } from '../lib/numbers'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { makeSortSeed } from '../lib/sortSeed'
import { transformCase } from '../lib/strings'
import type { AttributeType, Prisma } from '@prisma/client'
const MIN_CATEGORIES_TO_SHOW = 8
const MIN_ATTRIBUTES_TO_SHOW = 8
const PAGE_SIZE = 30
const sortOptions = [
{
value: 'score-desc',
label: 'Score (High → Low)',
orderBy: {
key: 'overallScore',
direction: 'desc',
},
},
{
value: 'score-asc',
label: 'Score (Low → High)',
orderBy: {
key: 'overallScore',
direction: 'asc',
},
},
{
value: 'name-asc',
label: 'Name (A → Z)',
orderBy: {
key: 'name',
direction: 'asc',
},
},
{
value: 'name-desc',
label: 'Name (Z → A)',
orderBy: {
key: 'name',
direction: 'desc',
},
},
{
value: 'recent',
label: 'Date listed (New → Old)',
orderBy: {
key: 'listedAt',
direction: 'desc',
},
},
{
value: 'oldest',
label: 'Date listed (Old → New)',
orderBy: {
key: 'listedAt',
direction: 'asc',
},
},
] as const satisfies {
value: string
label: string
orderBy: {
key: keyof Prisma.ServiceSelect
direction: 'asc' | 'desc'
}
}[]
const defaultSortOption = sortOptions[0]
const modeOptions = [
{
value: 'or',
label: 'OR',
},
{
value: 'and',
label: 'AND',
},
] as const satisfies {
value: string
label: string
}[]
const attributeOptions = [
{
value: 'yes',
prefix: 'Has',
},
{
value: 'no',
prefix: 'Not',
},
{
value: '',
prefix: '',
},
] as const satisfies {
value: string
prefix: string
}[]
const {
data: filters,
hasDefaultData: hasDefaultFilters,
redirectUrl,
} = zodParseQueryParamsStoringErrors(
{
categories: z.array(z.string()),
verification: z
.array(verificationStatusesZodEnumBySlug.transform((slug) => verificationStatusSlugToId(slug)))
.default(
verificationStatuses
.filter((verification) => verification.default)
.map((verification) => verification.slug)
),
'min-score': z.coerce.number().default(0),
'user-rating': z.coerce.number().int().min(0).max(5).default(0),
q: z.string().default(''),
currencies: z.array(currenciesZodEnumBySlug.transform((slug) => currencySlugToId(slug))),
'currency-mode': zodEnumFromConstant(modeOptions, 'value').default('or'),
'attribute-mode': zodEnumFromConstant(modeOptions, 'value').default('or'),
sort: zodEnumFromConstant(sortOptions, 'value').default(defaultSortOption.value),
'sort-seed': z
.string()
.transform((seed) => (Astro.url.searchParams.has('page') ? seed : makeSortSeed()))
.default(makeSortSeed()),
'max-kyc': z.coerce.number().int().min(0).max(4).default(4),
networks: z.array(zodEnumFromConstant(networks, 'slug')),
attr: z
.record(z.coerce.number().int().positive(), zodEnumFromConstant(attributeOptions, 'value'))
.optional(),
page: z.coerce.number().int().min(1).default(1),
},
Astro,
{
ignoredKeysForDefaultData: ['sort-seed'],
cleanUrl: {
removeUneededObjectParams: true,
removeParams: {
verification: { if: 'default' },
q: { if: 'default' },
sort: { if: 'default' },
'currency-mode': { if: 'another-is-unset', prop: 'currencies' },
'attribute-mode': { if: 'another-is-unset', prop: 'attr' },
'min-score': { if: 'default' },
'user-rating': { if: 'default' },
'max-kyc': { if: 'default' },
},
},
}
)
if (redirectUrl) return Astro.redirect(redirectUrl.toString())
export type ServicesFiltersObject = typeof filters
const [categories, [services, totalServices, hadToIncludeCommunityContributed]] =
await Astro.locals.banners.tryMany([
[
'Unable to load category filters.',
() =>
prisma.category.findMany({
select: {
name: true,
slug: true,
icon: true,
_count: {
select: {
services: true,
},
},
},
}),
],
[
'Unable to load services.',
async () => {
const groupedAttributes = groupBy(
Object.entries(filters.attr ?? {}).flatMap(([key, value]) => {
const id = parseIntWithFallback(key)
if (id === null) return []
return [{ id, value }]
}),
'value'
)
const where = {
listedAt: {
lte: new Date(),
},
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: filters.verification,
},
serviceVisibility: ServiceVisibility.PUBLIC,
overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and'
? { hasEvery: filters.currencies }
: { hasSome: filters.currencies }
: undefined,
kycLevel: {
lte: filters['max-kyc'],
},
AND: [
...(filters['user-rating'] > 0
? [
{
averageUserRating: {
gte: filters['user-rating'],
},
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length
? [
{
OR: [
...(filters.networks.includes('onion') ? [{ onionUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('i2p') ? [{ i2pUrls: { isEmpty: false } }] : []),
...(filters.networks.includes('clearnet') ? [{ serviceUrls: { isEmpty: false } }] : []),
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.attr && (groupedAttributes.yes?.length ?? 0) + (groupedAttributes.no?.length ?? 0) > 0
? [
{
AND: [
...(groupedAttributes.yes && groupedAttributes.yes.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.yes.map(
({ id }) =>
({
attributes: {
some: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
...(groupedAttributes.no && groupedAttributes.no.length > 0
? [
{
[filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.no.map(
({ id }) =>
({
attributes: {
none: {
attribute: {
id,
},
},
},
}) satisfies Prisma.ServiceWhereInput
),
},
]
: []),
],
},
]
: []),
],
} as const satisfies Prisma.ServiceWhereInput
const select = {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
} as const satisfies Prisma.ServiceSelect
let [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
where,
select,
})
let hadToIncludeCommunityContributed = false
if (totalServices === 0 && !where.verificationStatus.in.includes('COMMUNITY_CONTRIBUTED')) {
const [unsortedServiceCommunityServices, totalCommunityServices] =
await prisma.service.findManyAndCount({
where: {
...where,
verificationStatus: {
...where.verificationStatus,
in: [...where.verificationStatus.in, 'COMMUNITY_CONTRIBUTED'],
},
},
select,
})
if (totalCommunityServices !== 0) {
hadToIncludeCommunityContributed = true
unsortedServices = unsortedServiceCommunityServices
totalServices = totalCommunityServices
}
}
const rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy(
unsortedServices,
[selectedSort.orderBy.key, () => rng()],
[selectedSort.orderBy.direction, 'asc']
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfo = await prisma.service.findMany({
where: {
id: {
in: sortedServices.map((service) => service.id),
},
},
select: {
name: true,
slug: true,
description: true,
overallScore: true,
privacyScore: true,
trustScore: true,
kycLevel: true,
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
attributes: {
select: {
attribute: {
select: {
id: true,
slug: true,
title: true,
category: true,
type: true,
},
},
},
},
categories: {
select: {
name: true,
icon: true,
},
},
},
})
const sortedServicesWithInfo = orderBy(
unsortedServicesWithInfo,
[
selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(),
],
[selectedSort.orderBy.direction, 'asc']
)
return [sortedServicesWithInfo, totalServices, hadToIncludeCommunityContributed] as const
},
[[] as [], 0, false] as const,
],
])
const attributeIcons = {
GOOD: {
icon: 'ri:check-line',
iconClass: 'text-green-400',
},
BAD: {
icon: 'ri:close-line',
iconClass: 'text-red-400',
},
WARNING: {
icon: 'ri:alert-line',
iconClass: 'text-yellow-400',
},
INFO: {
icon: 'ri:information-line',
iconClass: 'text-blue-400',
},
} as const satisfies Record<AttributeType, { icon: string; iconClass: string }>
const attributes = await Astro.locals.banners.try(
'Unable to load attribute filters.',
() =>
prisma.attribute.findMany({
select: {
id: true,
slug: true,
title: true,
category: true,
type: true,
_count: {
select: {
services: true,
},
},
},
orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
}),
[]
)
const attributesByCategory = orderBy(
Object.entries(
groupBy(
attributes.map((attr) => ({
...attr,
...attributeIcons[attr.type],
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
value: filters.attr?.[attr.id] || undefined,
})),
'category'
)
).map(([category, attributes]) => ({
category,
attributes: orderBy(
attributes,
['value', 'type', '_count.services', 'title'],
['asc', 'asc', 'desc', 'asc']
).map((attr, i) => ({
...attr,
showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined,
})),
})),
['category'],
['asc']
)
const categoriesSorted = orderBy(
categories?.map((category) => {
const checked = filters.categories.includes(category.slug)
return {
...category,
checked,
}
}),
['checked', '_count.services', 'name'],
['desc', 'desc', 'asc']
).map((category, i) => ({
...category,
showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked,
}))
const filtersOptions = {
currencies,
categories: categoriesSorted,
sort: sortOptions,
modeOptions,
network: networks,
verification: verificationStatuses,
attributesByCategory,
} as const
export type ServicesFiltersOptions = typeof filtersOptions
//
---
<BaseLayout
pageTitle="Find KYC-free Services"
description="Find services that don't require KYC (Know Your Customer) verification for better privacy and control over your data."
widthClassName="max-w-none"
htmx
showSplashText
breadcrumbs={[
{
name: 'Services',
url: '/',
},
]}
>
<div class="flex flex-col gap-4 sm:flex-row sm:gap-8">
<div class="flex items-stretch sm:hidden">
{
!hasDefaultFilters ? (
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4">
{filters.q && (
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
)}
{!areEqualArraysWithoutOrder(
filters.verification,
filtersOptions.verification
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
filters.verification.map((verificationStatus) => {
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
/>
)
})}
{filters.categories.map((categorySlug) => {
const category = categories?.find((c) => c.slug === categorySlug)
if (!category) return null
return (
<ServiceFiltersPill
text={category.name}
icon={category.icon}
searchParamName="categories"
searchParamValue={categorySlug}
/>
)
})}
{filters.currencies.map((currencyId) => {
const currency = getCurrencyInfo(currencyId)
return (
<ServiceFiltersPill
text={currency.name}
searchParamName="currencies"
searchParamValue={currency.slug}
icon={currency.icon}
/>
)
})}
{filters.networks.map((network) => {
const networkOption = getNetworkInfo(network)
return (
<ServiceFiltersPill
text={networkOption.name}
icon={networkOption.icon}
searchParamName="networks"
searchParamValue={network}
/>
)
})}
{filters['max-kyc'] < 4 && (
<ServiceFiltersPill
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
icon="ri:shield-keyhole-line"
searchParamName="max-kyc"
/>
)}
{filters['user-rating'] > 0 && (
<ServiceFiltersPill
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
icon="ri:star-fill"
searchParamName="user-rating"
/>
)}
{filters['min-score'] > 0 && (
<ServiceFiltersPill
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
icon="ri:medal-line"
searchParamName="min-score"
/>
)}
{filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
<ServiceFiltersPill
text="Attributes: AND"
icon="ri:filter-3-line"
searchParamName="attribute-mode"
searchParamValue="and"
/>
)}
{filters.attr &&
Object.entries(filters.attr)
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
return (
<ServiceFiltersPill
text={`${prefix}: ${attribute.title}`}
searchParamName={`attr-${attributeId}`}
searchParamValue={attributeValue}
/>
)
})}
</div>
) : (
<div class="text-day-500 flex flex-1 items-center">No filters</div>
)
}
<Button as="label" for="show-filters" label="Filters" icon="ri:filter-3-line" />
</div>
<input
type="checkbox"
id="show-filters"
name="show-filters"
class="peer hidden"
checked={Astro.url.searchParams.has('show-filters')}
/>
<div
class="bg-night-700 fixed top-0 left-0 z-50 h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 sm:relative sm:z-auto sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
>
<ServicesFilters
searchResultsId="search-results"
showFiltersId="show-filters"
filters={{
...filters,
'sort-seed': makeSortSeed(),
}}
options={filtersOptions}
hasDefaultFilters={hasDefaultFilters}
/>
</div>
<ServicesSearchResults
services={services}
id="search-results"
currentPage={filters.page}
total={totalServices}
pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']}
filters={filters}
hadToIncludeCommunityContributed={hadToIncludeCommunityContributed}
/>
</div>
{
totalServices > PAGE_SIZE && (
<Pagination
currentPage={filters.page}
totalPages={Math.ceil(totalServices / PAGE_SIZE)}
sortSeed={filters['sort-seed']}
class="js:hidden mt-8"
/>
)
}
</BaseLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for removing sort-seed from URL. //
// This helps keep URLs cleaner when sharing. //
////////////////////////////////////////////////////////////
import { addOnLoadEventListener } from '../lib/onload'
addOnLoadEventListener(() => {
const url = new URL(window.location.href)
if (url.searchParams.has('sort-seed')) {
url.searchParams.delete('sort-seed')
window.history.replaceState({}, '', url)
}
})
</script>

72
web/src/pages/karma.mdx Normal file
View File

@@ -0,0 +1,72 @@
---
layout: ../layouts/MarkdownLayout.astro
title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works"
author: KYCnot.me
pubDate: 2025-05-15
---
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro'
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.
## How to Earn Karma
There are several ways to earn karma points:
1. **Comment Approval** (+1 point)
- When your comment moves from 'unmoderated' to 'approved' status
- This is the basic reward for contributing a valid comment
2. **Comment Verification** (+5 points)
- When your comment is marked as 'verified'
- This is a significant reward for providing particularly valuable or verified information
3. **Upvotes**
- Each upvote on your comment adds +1 to your karma
- Similarly, each downvote reduces your karma by -1
- This allows the community to reward helpful contributions
## Karma Penalties
The system also includes penalties to discourage spam and low-quality content:
1. **Spam Detection** (-10 points)
- If your comment is marked as suspicious/spam
- This is a significant penalty to discourage spam behavior
- If the spam mark is removed, the 10 points are restored
## Karma Tracking
The system maintains a detailed record of all karma changes through:
1. **Karma Transactions**
- Every karma change is recorded as a transaction
- Each transaction includes:
- The action that triggered it
- The number of points awarded/deducted
- A description of why the karma changed
- The related comment (if applicable)
2. **Total Karma**
- Your total karma is displayed on your profile
- It's the sum of all your karma transactions
- This score helps establish your reputation in the community
## Impact of Karma
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
- Active participation in the community
- History of providing valuable information
- Trustworthiness of your contributions
- Commitment to community standards
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
## Unlocking Actions with Karma
<KarmaUnlocksTable />

View File

@@ -0,0 +1,332 @@
---
import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../components/Button.astro'
import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro'
import { getNotificationTypeInfo } from '../constants/notificationTypes'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls'
const user = Astro.locals.user
if (!user) return Astro.redirect(makeLoginUrl(Astro.url))
const PAGE_SIZE = 20
const { data: params } = zodParseQueryParamsStoringErrors(
{
page: z.coerce.number().int().min(1).default(1),
},
Astro
)
const skip = (params.page - 1) * PAGE_SIZE
const [dbNotifications, notificationPreferences, totalNotifications] = await Astro.locals.banners.tryMany([
[
'Error while fetching notifications',
() =>
prisma.notification.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: 'desc',
},
skip,
take: PAGE_SIZE,
select: {
id: true,
type: true,
createdAt: true,
read: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: {
select: {
id: true,
},
},
status: true,
content: true,
communityNote: true,
service: {
select: {
slug: true,
name: true,
},
},
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
},
}),
[],
],
[
'Error while fetching notification preferences',
() =>
getOrCreateNotificationPreferences(user.id, {
id: true,
enableOnMyCommentStatusChange: true,
enableAutowatchMyComments: true,
enableNotifyPendingRepliesOnWatch: true,
}),
null,
],
[
'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }),
0,
],
])
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
const notificationPreferenceFields = [
{
id: 'enableOnMyCommentStatusChange',
label: 'Notify me when my comment status changes.',
icon: 'ri:chat-check-line',
},
{
id: 'enableAutowatchMyComments',
label: 'Autowatch my comments to receive notifications when they get a new reply.',
icon: 'ri:eye-line',
},
{
id: 'enableNotifyPendingRepliesOnWatch',
label: 'Notify me also about unmoderated replies for watched comments.',
icon: 'ri:chat-delete-line',
},
] as const satisfies {
id: Omit<keyof NonNullable<typeof notificationPreferences>, 'id'>
label: string
icon: string
}[]
const notifications = dbNotifications.map((notification) => ({
...notification,
typeInfo: getNotificationTypeInfo(notification.type),
title: makeNotificationTitle(notification, user),
content: makeNotificationContent(notification),
link: makeNotificationLink(notification, Astro.url.origin),
}))
---
<BaseLayout
pageTitle="Notifications"
description="View your notifications and manage your notification preferences."
widthClassName="max-w-screen-lg"
ogImage={{ template: 'generic', title: 'Notifications' }}
>
<section class="mx-auto w-full">
<div class="mb-4 flex items-center justify-between">
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
Notifications
</h1>
<form method="POST" action={actions.notification.updateReadStatus}>
<input type="hidden" name="notificationId" value="all" />
<input type="hidden" name="read" value="true" />
<Button
type="submit"
label="Mark all as read"
icon="ri:check-double-line"
disabled={notifications.length === 0}
color="white"
/>
</form>
</div>
{
notifications.length === 0 ? (
<div class="flex flex-col items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900 p-10 text-center shadow-sm">
<Icon name="material-symbols:notifications-outline" class="size-16 text-zinc-600" />
<h3 class="font-title mt-2 text-zinc-400">No notifications</h3>
</div>
) : (
<div class="space-y-2">
{notifications.map((notification) => (
<div
class={cn('rounded-lg border border-l-4 border-zinc-800 bg-zinc-900 p-3 shadow-sm', {
'border-l-blue-600': !notification.read,
})}
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<div class="mt-0.5 rounded-md bg-zinc-800 p-2">
<Icon name={notification.typeInfo.icon} class="size-5 text-zinc-300" />
</div>
<div>
<div class="font-medium text-zinc-200">{notification.title}</div>
<p class="text-sm text-zinc-400">{notification.content}</p>
<div class="mt-1 text-xs text-zinc-500">
<TimeFormatted date={notification.createdAt} prefix={false} caseType="sentence" />
</div>
</div>
</div>
<div class="flex items-center gap-2">
<form method="POST" action={actions.notification.updateReadStatus}>
<input type="hidden" name="notificationId" value={notification.id} />
<input type="hidden" name="read" value={notification.read ? 'false' : 'true'} />
<Tooltip
as="button"
text={notification.read ? 'Mark as unread' : 'Mark as read'}
position="left"
type="submit"
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
>
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" />
</Tooltip>
</form>
{notification.link && (
<a
href={notification.link}
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
>
<Icon name="ri:arrow-right-line" class="size-4" />
<span class="sr-only">View details</span>
</a>
)}
</div>
</div>
</div>
))}
{totalPages > 1 && (
<div class="mt-8 flex justify-center gap-4">
<form method="GET" action="/notifications" class="inline">
<input type="hidden" name="page" value={params.page - 1} />
<button
type="submit"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
disabled={params.page <= 1}
>
Previous
</button>
</form>
<span class="inline-flex items-center px-2 text-sm text-zinc-400">
Page {params.page} of {totalPages}
</span>
<form method="GET" action="/notifications" class="inline">
<input type="hidden" name="page" value={params.page + 1} />
<button
type="submit"
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
disabled={params.page >= totalPages}
>
Next
</button>
</form>
</div>
)}
</div>
)
}
{
!!notificationPreferences && (
<div class="mt-8">
<h2 class="font-title mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
Notification Settings
</h2>
<form
method="POST"
action={actions.notification.preferences.update}
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
>
{notificationPreferenceFields.map((field) => (
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
{field.label}
</span>
<input
type="checkbox"
name={field.id}
checked={notificationPreferences[field.id]}
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
/>
</label>
))}
<div class="mt-4 flex justify-end">
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div>
</form>
</div>
)
}
</section>
</BaseLayout>

View File

@@ -0,0 +1,24 @@
import { ogImageTemplates } from '../components/OgImage'
import { urlParamsToObject } from '../lib/urls'
import type { APIRoute } from 'astro'
export const GET: APIRoute = ({ url }) => {
const { template, ...props } = urlParamsToObject(url.searchParams)
if (!template) return ogImageTemplates.default()
if (!(template in ogImageTemplates)) {
console.error(`Invalid template: "${template}"`)
return ogImageTemplates.default()
}
const response = ogImageTemplates[template as keyof typeof ogImageTemplates](props)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!response) {
console.error(`Cannot generate image for template: ${template} and props: ${JSON.stringify(props)}`)
return ogImageTemplates.default()
}
return response
}

View File

@@ -0,0 +1,163 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import AdminOnly from '../../components/AdminOnly.astro'
import Chat from '../../components/Chat.astro'
import ServiceCard from '../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { parseIntWithFallback } from '../../lib/numbers'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
import { formatDateShort } from '../../lib/timeAgo'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to view service suggestion' }))
}
const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) {
return Astro.rewrite('/404')
}
const serviceSuggestion = await Astro.locals.banners.try('Error fetching service suggestion', async () =>
prisma.serviceSuggestion.findUnique({
select: {
id: true,
status: true,
notes: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
description: true,
overallScore: true,
kycLevel: true,
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
},
messages: {
select: {
id: true,
content: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
where: {
id: serviceSuggestionId,
userId: user.id,
},
})
)
if (!serviceSuggestion) {
if (user.admin) return Astro.redirect(`/admin/service-suggestions/${serviceSuggestionIdRaw}`)
return Astro.rewrite('/404')
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
---
<BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Service suggestion`}
description="View your service suggestion"
ogImage={{ template: 'generic', title: serviceSuggestion.service.name }}
widthClassName="max-w-screen-md"
htmx
breadcrumbs={[
{
name: 'Service suggestions',
url: '/service-suggestion',
},
{
name: `${serviceSuggestion.service.name} | Service suggestion`,
},
]}
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
<AdminOnly>
<a
href={`/admin/service-suggestions/${serviceSuggestionIdRaw}`}
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:lock-line" class="size-4" />
View in admin
</a>
</AdminOnly>
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 grid grid-cols-2 gap-6 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span>Status:</span>
<span
class={cn(
'border-night-500 bg-night-800 box-content inline-flex h-8 items-center justify-center gap-1 rounded-full border px-2',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="size-4" />
{statusInfo.label}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span>Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</span>
</div>
</div>
<div class="mt-6">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div>
<div class="text-sm whitespace-pre-wrap">
{serviceSuggestion.notes ?? <span class="italic">Empty</span>}
</div>
</div>
</section>
<Chat
messages={serviceSuggestion.messages}
title="Chat with moderators"
userId={user.id}
action={actions.serviceSuggestion.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
class="mt-12"
/>
</BaseLayout>

View File

@@ -0,0 +1,102 @@
---
import { actions, isInputError } from 'astro:actions'
import { z } from 'astro:content'
import Captcha from '../../components/Captcha.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import ServiceCard from '../../components/ServiceCard.astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
}
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
if (result && !result.error) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const { data: params } = zodParseQueryParamsStoringErrors(
{
serviceId: z.coerce.number().int().positive(),
notes: z.string().default(''),
},
Astro
)
if (!params.serviceId) return Astro.rewrite('/404')
const service = await Astro.locals.banners.try(
'Failed to fetch service',
async () =>
prisma.service.findUnique({
select: {
id: true,
name: true,
slug: true,
description: true,
overallScore: true,
kycLevel: true,
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
categories: {
select: {
name: true,
icon: true,
},
},
},
where: { id: params.serviceId },
}),
null
)
if (!service) return Astro.rewrite('/404')
---
<BaseLayout
pageTitle="Edit service"
description="Suggest an edit to service"
ogImage={{ template: 'generic', title: 'Edit service' }}
widthClassName="max-w-screen-md"
breadcrumbs={[
{
name: 'Service suggestions',
url: '/service-suggestion',
},
{
name: 'Edit service',
},
]}
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
<ServiceCard service={service} withoutLink class="mb-6" />
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
<input type="hidden" name="serviceId" value={params.serviceId} />
<InputTextArea
label="Note for Moderators"
name="notes"
value={params.notes}
rows={10}
placeholder="List the changes you want us to make to the service. Example: 'Add X, Y and Z attributes' 'Monero is accepted'. Provide supporting evidence."
error={inputErrors.notes}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<InputHoneypotTrap name="message" />
<InputSubmitButton />
</form>
</BaseLayout>

View File

@@ -0,0 +1,174 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { Picture } from 'astro:assets'
import { z } from 'astro:content'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import Button from '../../components/Button.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { zodEnumFromConstant } from '../../lib/arrays'
import { cn } from '../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to manage service suggestions' }))
}
const { data: filters } = zodParseQueryParamsStoringErrors(
{
serviceId: z.array(z.number().int().positive()).default([]),
status: z.array(zodEnumFromConstant(serviceSuggestionStatuses, 'value')).default([]),
},
Astro
)
const serviceSuggestions = await Astro.locals.banners.try('Error fetching service suggestions', async () =>
prisma.serviceSuggestion.findMany({
select: {
id: true,
type: true,
status: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
imageUrl: true,
verificationStatus: true,
},
},
},
where: {
id: filters.serviceId.length > 0 ? { in: filters.serviceId } : undefined,
status: filters.status.length > 0 ? { in: filters.status } : undefined,
userId: user.id,
},
orderBy: {
createdAt: 'desc',
},
})
)
if (!serviceSuggestions) {
return Astro.rewrite('/404')
}
const createResult = Astro.getActionResult(actions.serviceSuggestion.createService)
const success = !!createResult && !createResult.error
---
<BaseLayout
pageTitle="My service suggestions"
description="Manage your service suggestions"
ogImage={{ template: 'generic', title: 'Service suggestions' }}
widthClassName="max-w-screen-md"
breadcrumbs={[
{
name: 'Service suggestions',
url: '/service-suggestion',
},
{
name: 'My suggestions',
},
]}
>
<div
class="xs:flex-row xs:flex-wrap xs:justify-between mb-8 flex flex-col items-center justify-center gap-4 text-center sm:mt-8"
>
<h1 class="font-title text-day-100 text-3xl">Service suggestions</h1>
<Button as="a" href="/service-suggestion/new" label="Create" icon="ri:add-line" />
</div>
{
success && (
<div class="mb-8 rounded-lg border border-green-500/30 bg-green-950 p-4 text-sm text-green-500">
<Icon name="ri:check-line" class="mr-2 inline-block size-4 text-green-500" />
Service suggestion submitted successfully!
</div>
)
}
{
serviceSuggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p>
) : (
<div class="-mx-4 overflow-x-auto px-4">
<div class="grid w-full min-w-min grid-cols-[1fr_auto_auto_auto_auto] place-content-center place-items-center gap-x-4 gap-y-2">
<p class="place-self-start">Service</p>
<p>Type</p>
<p>Status</p>
<p>Created</p>
<p>Actions</p>
{serviceSuggestions.map((suggestion) => {
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return (
<>
<a
href={`/service/${suggestion.service.slug}`}
class="inline-flex w-full min-w-32 items-center gap-2 hover:underline"
>
<Picture
src={suggestion.service.imageUrl ?? (defaultServiceImage as unknown as string)}
alt={suggestion.service.name}
width={32}
height={32}
class="inline-block size-8 min-w-8 shrink-0 rounded-md"
formats={['jxl', 'avif', 'webp']}
/>
<span class="shrink truncate">{suggestion.service.name}</span>
</a>
<Tooltip
as="span"
class="inline-flex items-center gap-1"
text={typeInfo.label}
classNames={{ tooltip: 'md:hidden!' }}
>
<Icon name={typeInfo.icon} class="size-4" />
<span class="hidden md:inline">{typeInfo.label}</span>
</Tooltip>
<Tooltip
as="span"
text={statusInfo.label}
class={cn(
'border-night-500 bg-night-800 box-content inline-flex h-8 items-center justify-center gap-1 rounded-full border px-2',
statusInfo.iconClass
)}
classNames={{ tooltip: 'md:hidden!' }}
>
<Icon name={statusInfo.icon} class="size-4" />
<span class="hidden md:inline">{statusInfo.label}</span>
</Tooltip>
<TimeFormatted date={suggestion.createdAt} caseType="sentence" prefix={false} />
<Button
as="a"
href={`/service-suggestion/${suggestion.id}`}
label="View"
icon="ri:eye-line"
/>
</>
)
})}
</div>
</div>
)
}
</BaseLayout>

View File

@@ -0,0 +1,308 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import {
SUGGESTION_DESCRIPTION_MAX_LENGTH,
SUGGESTION_NAME_MAX_LENGTH,
SUGGESTION_NOTES_MAX_LENGTH,
SUGGESTION_SLUG_MAX_LENGTH,
} from '../../actions/serviceSuggestion'
import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { currencies } from '../../constants/currencies'
import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const user = Astro.locals.user
if (!user) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
}
const result = Astro.getActionResult(actions.serviceSuggestion.createService)
if (result && !result.error && !result.data.hasDuplicates) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
}
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const [categories, attributes] = await Astro.locals.banners.tryMany([
[
'Failed to fetch categories',
() =>
prisma.category.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
icon: true,
},
}),
[],
],
[
'Failed to fetch attributes',
() =>
prisma.attribute.findMany({
orderBy: { category: 'asc' },
select: {
id: true,
title: true,
},
}),
[],
],
])
---
<BaseLayout
pageTitle="New service"
description="Suggest a new service to be added to KYCnot.me"
ogImage={{ template: 'generic', title: 'New service' }}
widthClassName="max-w-screen-md"
breadcrumbs={[
{
name: 'Service suggestions',
url: '/service-suggestion',
},
{
name: 'New service',
},
]}
>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
<form
method="POST"
action={actions.serviceSuggestion.createService}
enctype="multipart/form-data"
class="space-y-6"
>
{
result?.data?.hasDuplicates && (
<>
<div class="sm:border-night-300 sm:bg-night-500 mb-16 sm:rounded-lg sm:border sm:p-2 md:p-4">
<input type="hidden" name="skipDuplicateCheck" value="true" />
<h2 class="font-title flex items-center justify-center gap-2 text-2xl font-semibold text-red-500">
Possible duplicates found
</h2>
<p class="text-day-400 text-center">Is your service already listed below?</p>
<div class="mt-4 space-y-2">
{result.data.possibleDuplicates.map((duplicate) => {
const editServiceUrl = new URL('/service-suggestion/edit', Astro.url)
editServiceUrl.searchParams.set('serviceId', duplicate.id.toString())
if (result.data.extraNotes) {
editServiceUrl.searchParams.set('notes', result.data.extraNotes)
}
return (
<div class="border-night-400 bg-night-600 flex gap-4 rounded-lg border p-4 shadow-sm">
<div class="grow">
<p class="text-day-100 mb-1 text-lg font-medium">{duplicate.name}</p>
<p class="text-day-300 mb-3 line-clamp-2 text-sm">{duplicate.description}</p>
</div>
<div class="flex shrink-0 flex-col justify-center gap-2">
<a
href={`/service/${duplicate.slug}`}
target="_blank"
class="text-day-300 bg-night-300 hover:bg-night-400 flex items-center gap-1 rounded px-2.5 py-1.5 text-sm font-medium transition-colors"
>
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
View
</a>
<a
href={editServiceUrl.toString()}
target="_blank"
class="flex items-center gap-1 rounded bg-green-600 px-2.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Icon name="ri:edit-line" class="size-4 shrink-0" />
Submit as edit
</a>
</div>
</div>
)
})}
</div>
<div class="bg-night-400 mt-4 hidden h-px w-full sm:block" />
<div class="mt-4 flex items-center gap-2 px-4">
<p class="text-day-200 flex-1">None of these match?</p>
<button
type="submit"
class="bg-day-700 text-day-100 hover:bg-day-600 flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium"
>
<Icon name="ri:send-plane-2-line" class="size-4" />
Submit anyway
</button>
</div>
</div>
<h1 class="font-title mt-12 mb-6 block text-center text-3xl font-bold sm:hidden">
Review your suggestion
</h1>
</>
)
}
<InputText
label="Name"
name="name"
inputProps={{
required: true,
maxlength: SUGGESTION_NAME_MAX_LENGTH,
'data-generate-slug': true,
}}
error={inputErrors.name}
/>
<InputText
label="Slug"
name="slug"
description="Auto-generated from name. Only lowercase letters, numbers, and hyphens."
error={inputErrors.slug}
inputProps={{
required: true,
pattern: '^[a-z0-9\\-]+$',
placeholder: 'my-service-name',
minlength: 1,
maxlength: SUGGESTION_SLUG_MAX_LENGTH,
'data-generate-slug': true,
}}
/>
<InputTextArea
label="Description"
name="description"
id="description"
required
maxlength={SUGGESTION_DESCRIPTION_MAX_LENGTH}
error={inputErrors.description}
/>
<InputTextArea
label="Service URLs"
name="serviceUrls"
required
placeholder="https://example1.com\nhttps://example2.org"
error={inputErrors.serviceUrls}
/>
<InputTextArea
label="Terms of Service URLs"
name="tosUrls"
required
placeholder="https://example1.com/tos\nhttps://example2.org/terms"
error={inputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
name="onionUrls"
placeholder="http://example1.onion\nhttp://example2.onion"
error={inputErrors.onionUrls}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"
options={kycLevels.map((kycLevel) => ({
label: kycLevel.name,
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/4_`,
}))}
iconSize="md"
cardSize="md"
required
error={inputErrors.kycLevel}
/>
<InputCheckboxGroup
name="categories"
label="Categories"
required
options={categories.map((category) => ({
label: category.name,
value: category.id.toString(),
icon: category.icon,
}))}
error={inputErrors.categories}
/>
<InputCheckboxGroup
name="attributes"
label="Attributes"
options={attributes.map((attribute) => ({
label: attribute.title,
value: attribute.id.toString(),
}))}
error={inputErrors.attributes}
/>
<InputCardGroup
name="acceptedCurrencies"
label="Accepted Currencies"
options={currencies.map((currency) => ({
label: currency.name,
value: currency.id,
icon: currency.icon,
}))}
error={inputErrors.acceptedCurrencies}
required
multiple
/>
<InputImageFile
label="Service Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported."
error={inputErrors.imageFile}
square
required
/>
<InputTextArea
label="Note for Moderators"
name="notes"
id="notes"
error={inputErrors.notes}
maxlength={SUGGESTION_NOTES_MAX_LENGTH}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<InputHoneypotTrap name="message" />
<InputSubmitButton label={result?.data?.hasDuplicates ? 'Submit anyway' : 'Submit'} />
</form>
</BaseLayout>
<script>
document.addEventListener('astro:page-load', () => {
const triggerInputs = document.querySelectorAll<HTMLInputElement>('[data-generate-slug] input')
const slugInputs = document.querySelectorAll<HTMLInputElement>('input[name="slug"]')
triggerInputs.forEach((triggerInput) => {
triggerInput.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement
slugInputs.forEach((slugInput) => {
slugInput.value = target.value
.toLowerCase()
.replace(/[^a-z0-9\-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
})
})
})
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,912 @@
---
import { Icon } from 'astro-icon/components'
import { Picture } from 'astro:assets'
import { sortBy } from 'lodash-es'
import defaultServiceImage from '../../assets/fallback-service-image.jpg'
import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import { karmaUnlocks } from '../../constants/karmaUnlocks'
import { SUPPORT_EMAIL } from '../../constants/project'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import { getServiceUserRoleInfo } from '../../constants/serviceUserRoles'
import { verificationStatusesByValue } from '../../constants/verificationStatus'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma'
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
import { formatDateShort } from '../../lib/timeAgo'
import type { ProfilePage, WithContext } from 'schema-dts'
const username = Astro.params.username
if (!username) return Astro.rewrite('/404')
const user = await Astro.locals.banners.try('user', async () => {
return makeUserWithKarmaUnlocks(
await prisma.user.findUnique({
where: { name: Astro.params.username },
select: {
id: true,
name: true,
displayName: true,
link: true,
picture: true,
spammer: true,
verified: true,
admin: true,
verifier: true,
verifiedLink: true,
totalKarma: true,
createdAt: true,
_count: {
select: {
comments: true,
commentVotes: true,
karmaTransactions: true,
},
},
karmaTransactions: {
select: {
id: true,
points: true,
action: true,
description: true,
createdAt: true,
comment: {
select: {
id: true,
content: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
suggestions: {
select: {
id: true,
type: true,
status: true,
createdAt: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
where: { service: { serviceVisibility: 'PUBLIC' } },
orderBy: { createdAt: 'desc' },
take: 5,
},
comments: {
select: {
id: true,
content: true,
createdAt: true,
upvotes: true,
status: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
commentVotes: {
select: {
id: true,
downvote: true,
createdAt: true,
comment: {
select: {
id: true,
content: true,
service: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},
serviceAffiliations: {
select: {
role: true,
service: {
select: {
id: true,
name: true,
slug: true,
imageUrl: true,
verificationStatus: true,
},
},
},
orderBy: [{ role: 'asc' }, { service: { name: 'asc' } }],
},
},
})
)
})
if (!user) return Astro.rewrite('/404')
const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
---
<BaseLayout
pageTitle={`${user.name} - Account`}
description="Manage your user profile"
ogImage={{ template: 'generic', title: `${user.name} | Account` }}
widthClassName="max-w-screen-md"
className={{
main: 'space-y-6',
}}
htmx
schemas={[
{
'@context': 'https://schema.org',
'@type': 'ProfilePage',
url: new URL(`/u/${user.name}`, Astro.url).href,
name: `${user.displayName ?? user.name}'s Profile`,
dateCreated: user.createdAt.toISOString(),
mainEntity: {
'@type': 'Person',
name: user.displayName ?? user.name,
alternateName: user.displayName ? user.name : undefined,
image: user.picture ?? undefined,
url: new URL(`/u/${user.name}`, Astro.url).href,
sameAs: user.link ? [user.link] : undefined,
description: `User account for ${user.displayName ?? user.name} on KYCnot.me`,
identifier: [user.name, user.id.toString()],
jobTitle: user.admin ? 'Administrator' : user.verifier ? 'Moderator' : undefined,
memberOf: KYCNOTME_SCHEMA_MINI,
interactionStatistic: [
{
'@type': 'InteractionCounter',
interactionType: { '@type': 'WriteAction' },
userInteractionCount: user._count.comments,
},
{
'@type': 'InteractionCounter',
interactionType: { '@type': 'CommentAction' },
userInteractionCount: user._count.comments,
},
{
'@type': 'InteractionCounter',
interactionType: { '@type': 'LikeAction' },
userInteractionCount: user._count.commentVotes,
},
],
},
} satisfies WithContext<ProfilePage>,
]}
breadcrumbs={[
{
name: 'Users',
url: '/u',
},
{
name: user.displayName ?? user.name,
url: `/u/${user.name}`,
},
]}
>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center gap-4">
{
user.picture ? (
<img src={user.picture} alt="" class="ring-day-500/30 size-16 rounded-full ring-2" />
) : (
<div class="bg-day-500/10 ring-day-500/30 text-day-400 flex size-16 items-center justify-center rounded-full ring-2">
<Icon name="ri:user-3-line" class="size-8" />
</div>
)
}
<div>
<h1 class="font-title text-lg font-bold tracking-wider text-white">
{user.name}
{isCurrentUser && <span class="text-day-500 font-normal">(You)</span>}
</h1>
{user.displayName && <p class="text-day-200">{user.displayName}</p>}
<div class="mt-1 flex gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
verified
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
verifier
</span>
)
}
</div>
</div>
<nav class="ml-auto flex items-center gap-2">
<AdminOnly>
<Tooltip
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=${encodeURIComponent(Astro.url.href)}`}
class="inline-flex items-center gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-sm text-amber-400 shadow-xs transition-colors duration-200 hover:bg-amber-500/20 focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="Impersonate (admin)"
>
<Icon name="ri:spy-line" class="size-4" />
</Tooltip>
</AdminOnly>
<AdminOnly>
<Tooltip
as="a"
href={`/admin/users/${user.name}`}
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border p-2 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="Edit user (admin)"
>
<Icon name="ri:edit-line" class="size-4" />
</Tooltip>
</AdminOnly>
{
isCurrentUser && (
<Tooltip
as="a"
href="/account"
class="border-day-500/30 bg-day-500/10 text-day-400 hover:bg-day-500/20 focus:ring-day-500 inline-flex items-center gap-1 rounded-md border p-2 text-sm shadow-xs transition-colors duration-200 focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
text="Go to my account"
>
<Icon name="ri:eye-off-line" class="size-4" />
</Tooltip>
)
}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:alert-line" class="size-4" /> Report
</a>
</nav>
</header>
<div class="border-night-400 mt-6 border-t pt-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 class="font-title text-day-200 mb-4 text-sm">Profile Information</h3>
<ul class="flex flex-col items-start gap-2">
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:user-3-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Username</p>
<p class="text-day-300">{user.name}</p>
</div>
</li>
<li class="inline-flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:user-smile-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Display Name</p>
<p class="text-day-300">
{user.displayName ?? <span class="text-sm italic">Not set</span>}
</p>
</div>
</li>
{
!!user.link && (
<li class="inline-flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:link" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Website</p>
<a
href={user.link}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.link}
</a>
</div>
</li>
)
}
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Karma</p>
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p>
</div>
</li>
</ul>
</div>
<div id="account-status">
<h3 class="font-title text-day-200 mb-4 text-sm">Account Status</h3>
<ul class="space-y-3">
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:shield-check-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Account Type</p>
<div class="mt-1 flex flex-wrap gap-2">
{
user.admin && (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Admin
</span>
)
}
{
user.verified && (
<span class="rounded-full border border-blue-500/50 bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
Verified User
</span>
)
}
{
user.verifier && (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Verifier
</span>
)
}
{
!user.admin && !user.verified && !user.verifier && (
<span class="border-day-700/50 bg-day-700/20 text-day-400 rounded-full border px-2 py-0.5 text-xs">
Standard User
</span>
)
}
</div>
</div>
</li>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:spam-2-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Spam Status</p>
{
user.spammer ? (
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Spammer
</span>
) : (
<span class="rounded-full border border-green-500/50 bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
Not Flagged
</span>
)
}
</div>
</li>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Joined</p>
<p class="text-day-300">
{
formatDateShort(user.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
{
user.verifiedLink && (
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:check-double-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Verified as related to</p>
<a
href={user.verifiedLink}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.verifiedLink}
</a>
</div>
</li>
)
}
</ul>
</div>
</div>
</div>
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
Service Affiliations
</h2>
</header>
{
user.serviceAffiliations.length > 0 ? (
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
const statusIcon = {
...verificationStatusesByValue,
APPROVED: undefined,
}[affiliation.service.verificationStatus]
return (
<li class="shrink-0">
<a
href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
>
<Picture
src={affiliation.service.imageUrl ?? (defaultServiceImage as unknown as string)}
alt={affiliation.service.name}
width={40}
height={40}
class="size-10 shrink-0 rounded-lg"
/>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span>
</div>
<div class="text-day-300 flex items-center gap-1 font-semibold">
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
{affiliation.service.name}
</span>
{statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
<Icon
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1',
statusIcon.classNames.icon
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)
}
</section>
<section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-unlocks"
>
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
Karma Unlocks
</h2>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
>Learn about karma</a
>
</p>
</div>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3">
<h3 class="font-title border-day-700/20 text-day-200 border-b pb-2 text-sm">Positive unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn('font-medium', user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400')}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
<div class="space-y-3">
<h3 class="font-title border-b border-red-500/20 pb-2 text-sm text-red-400">Negative unlocks</h3>
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))
}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
</div>
</div>
</section>
<div class="space-y-6">
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:chat-3-line" class="mr-2 inline-block size-5" />
Recent Comments
</h2>
<span class="text-day-500">{user._count.comments.toLocaleString()} comments</span>
</header>
{
user.comments.length === 0 ? (
<p class="text-day-400">No comments yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Comment</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Upvotes</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.comments.map((comment) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a href={`/service/${comment.service.slug}`} class="text-blue-400 hover:underline">
{comment.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3">
<p class="line-clamp-1">{comment.content}</p>
</td>
<td class="px-4 py-3 text-center">
<span class="border-day-700/20 bg-night-800/30 text-day-300 inline-flex rounded-full border px-2 py-0.5 text-xs">
{comment.status}
</span>
</td>
<td class="text-day-300 px-4 py-3 text-center text-xs">
<span class="inline-flex items-center">
<Icon name="ri:thumb-up-line" class="mr-1 size-4" /> {comment.upvotes}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={comment.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
{
user.karmaUnlocks.voteComments || user._count.commentVotes ? (
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:thumb-up-line" class="mr-2 inline-block size-5" />
Recent Votes
</h2>
<span class="text-day-500">{user._count.commentVotes.toLocaleString()} votes</span>
</header>
{user.commentVotes.length === 0 ? (
<p class="text-day-400">No votes yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Comment</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Vote</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.commentVotes.map((vote) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service/${vote.comment.service.slug}`}
class="text-blue-400 hover:underline"
>
{vote.comment.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3">
<p class="line-clamp-1">{vote.comment.content}</p>
</td>
<td class="px-4 py-3 text-center">
{vote.downvote ? (
<span class="inline-flex items-center text-red-400">
<Icon name="ri:thumb-down-fill" class="size-4" />
</span>
) : (
<span class="inline-flex items-center text-green-400">
<Icon name="ri:thumb-up-fill" class="size-4" />
</span>
)}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={vote.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
) : (
<section class="border-night-400 bg-night-400/5 flex flex-wrap items-center justify-between gap-2 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<h2 class="font-title text-day-200 grow-9999 text-xl font-bold">
<Icon name="ri:thumb-up-line" class="mr-2 inline-block size-5" />
Recent Votes
</h2>
<span class="text-day-500 inline-flex grow items-center justify-center gap-1">
<Icon name="ri:lock-line" class="inline-block size-5" />
Locked
</span>
</section>
)
}
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
Recent Suggestions
</h2>
</header>
{
user.suggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.suggestions.map((suggestion) => {
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline"
>
{suggestion.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={suggestion.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
Recent Karma Transactions
</h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{
user.karmaTransactions.length === 0 ? (
<p class="text-day-400">No karma transactions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">{transaction.action}</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
</div>
</BaseLayout>