Release 2025-05-19
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user