Release 2025-05-19
This commit is contained in:
101
web/src/pages/404.astro
Normal file
101
web/src/pages/404.astro
Normal 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
105
web/src/pages/500.astro
Normal 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
211
web/src/pages/about.md
Normal 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.
|
||||
75
web/src/pages/access-denied.astro
Normal file
75
web/src/pages/access-denied.astro
Normal 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>
|
||||
145
web/src/pages/account/edit.astro
Normal file
145
web/src/pages/account/edit.astro
Normal 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>
|
||||
117
web/src/pages/account/generate.astro
Normal file
117
web/src/pages/account/generate.astro
Normal 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>
|
||||
32
web/src/pages/account/impersonate.astro
Normal file
32
web/src/pages/account/impersonate.astro
Normal 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)}`)
|
||||
---
|
||||
908
web/src/pages/account/index.astro
Normal file
908
web/src/pages/account/index.astro
Normal 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>
|
||||
82
web/src/pages/account/login.astro
Normal file
82
web/src/pages/account/login.astro
Normal 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>
|
||||
8
web/src/pages/account/logout.astro
Normal file
8
web/src/pages/account/logout.astro
Normal 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') || '/')
|
||||
---
|
||||
114
web/src/pages/account/welcome.astro
Normal file
114
web/src/pages/account/welcome.astro
Normal 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>
|
||||
751
web/src/pages/admin/attributes.astro
Normal file
751
web/src/pages/admin/attributes.astro
Normal 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>
|
||||
237
web/src/pages/admin/comments.astro
Normal file
237
web/src/pages/admin/comments.astro
Normal 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">> 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>
|
||||
76
web/src/pages/admin/index.astro
Normal file
76
web/src/pages/admin/index.astro
Normal 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>
|
||||
195
web/src/pages/admin/service-suggestions/[id].astro
Normal file
195
web/src/pages/admin/service-suggestions/[id].astro
Normal 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>
|
||||
385
web/src/pages/admin/service-suggestions/index.astro
Normal file
385
web/src/pages/admin/service-suggestions/index.astro
Normal 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>
|
||||
1367
web/src/pages/admin/services/[slug]/edit.astro
Normal file
1367
web/src/pages/admin/services/[slug]/edit.astro
Normal file
File diff suppressed because it is too large
Load Diff
616
web/src/pages/admin/services/index.astro
Normal file
616
web/src/pages/admin/services/index.astro
Normal 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>
|
||||
366
web/src/pages/admin/services/new.astro
Normal file
366
web/src/pages/admin/services/new.astro
Normal 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>
|
||||
626
web/src/pages/admin/users/[username].astro
Normal file
626
web/src/pages/admin/users/[username].astro
Normal 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>
|
||||
377
web/src/pages/admin/users/index.astro
Normal file
377
web/src/pages/admin/users/index.astro
Normal 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>
|
||||
403
web/src/pages/attributes.astro
Normal file
403
web/src/pages/attributes.astro
Normal 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
474
web/src/pages/events.astro
Normal 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>
|
||||
44
web/src/pages/files/[...path].ts
Normal file
44
web/src/pages/files/[...path].ts
Normal 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
700
web/src/pages/index.astro
Normal 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
72
web/src/pages/karma.mdx
Normal 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 />
|
||||
332
web/src/pages/notifications.astro
Normal file
332
web/src/pages/notifications.astro
Normal 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>
|
||||
24
web/src/pages/ogimage.png.ts
Normal file
24
web/src/pages/ogimage.png.ts
Normal 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
|
||||
}
|
||||
163
web/src/pages/service-suggestion/[id].astro
Normal file
163
web/src/pages/service-suggestion/[id].astro
Normal 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>
|
||||
102
web/src/pages/service-suggestion/edit.astro
Normal file
102
web/src/pages/service-suggestion/edit.astro
Normal 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>
|
||||
174
web/src/pages/service-suggestion/index.astro
Normal file
174
web/src/pages/service-suggestion/index.astro
Normal 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>
|
||||
308
web/src/pages/service-suggestion/new.astro
Normal file
308
web/src/pages/service-suggestion/new.astro
Normal 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>
|
||||
1506
web/src/pages/service/[slug].astro
Normal file
1506
web/src/pages/service/[slug].astro
Normal file
File diff suppressed because it is too large
Load Diff
912
web/src/pages/u/[username].astro
Normal file
912
web/src/pages/u/[username].astro
Normal 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>
|
||||
Reference in New Issue
Block a user