Files
kycnotme/web/src/pages/admin/users/[username].astro
2025-06-01 15:11:37 +00:00

497 lines
16 KiB
Plaintext

---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import FormSection from '../../../components/FormSection.astro'
import InputCardGroup from '../../../components/InputCardGroup.astro'
import InputImageFile from '../../../components/InputImageFile.astro'
import InputSelect from '../../../components/InputSelect.astro'
import InputSubmitButton from '../../../components/InputSubmitButton.astro'
import InputText from '../../../components/InputText.astro'
import InputTextArea from '../../../components/InputTextArea.astro'
import MyPicture from '../../../components/MyPicture.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
import { getServiceUserRoleInfo, serviceUserRoles } from '../../../constants/serviceUserRoles'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { prisma } from '../../../lib/prisma'
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 addKarmaTransactionResult = Astro.getActionResult(actions.admin.user.karmaTransactions.add)
Astro.locals.banners.addIfSuccess(addKarmaTransactionResult, 'Karma transaction added 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,
moderator: true,
spammer: true,
verifiedLink: true,
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
id: true,
name: true,
displayName: true,
picture: 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.displayName ?? user.name} - User`}
widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }}
>
<div class="mt-12">
{
!!user.picture && (
<MyPicture
src={user.picture}
alt=""
width={80}
height={80}
class="mx-auto mb-2 block size-20 rounded-full"
/>
)
}
<h1
class="font-title mb-2 flex items-center justify-center gap-2 text-center text-3xl leading-none font-bold"
>
{user.displayName ? `${user.displayName} (${user.name})` : user.name}
</h1>
<div class="mb-4 flex flex-wrap justify-center gap-2">
{user.admin && <BadgeSmall color="green" text="Admin" icon="ri:shield-star-fill" />}
{user.verified && <BadgeSmall color="cyan" text="Verified" icon="ri:verified-badge-fill" />}
{user.moderator && <BadgeSmall color="blue" text="Moderator" icon="ri:graduation-cap-fill" />}
{user.spammer && <BadgeSmall color="red" text="Spammer" icon="ri:alert-fill" />}
</div>
<div class="flex justify-center gap-2">
<Button
as="a"
href={`/u/${user.name}`}
icon="ri:global-line"
color="success"
shadow
size="sm"
label="View public profile"
/>
{
Astro.locals.user && user.id !== Astro.locals.user.id && !user.admin && (
<Button
as="a"
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account`}
data-astro-prefetch="tap"
icon="ri:spy-line"
color="gray"
size="sm"
label="Impersonate"
/>
)
}
</div>
</div>
<FormSection title="Edit profile">
<form
method="POST"
action={actions.admin.user.update}
enctype="multipart/form-data"
class="space-y-2"
data-astro-reload
>
<input type="hidden" name="id" value={user.id} />
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<InputText
label="Name"
name="name"
error={updateInputErrors.name}
inputProps={{ value: user.name, required: true }}
/>
<InputText
label="Display Name"
name="displayName"
error={updateInputErrors.displayName}
inputProps={{ value: user.displayName ?? '', maxlength: 50 }}
/>
<InputText
label="Link"
name="link"
error={updateInputErrors.link}
inputProps={{ value: user.link ?? '', type: 'url' }}
/>
<InputText
label="Verified Link"
name="verifiedLink"
error={updateInputErrors.verifiedLink}
inputProps={{ value: user.verifiedLink, type: 'url' }}
/>
</div>
<InputImageFile
label="Profile Picture Upload"
name="pictureFile"
value={user.picture}
error={updateInputErrors.pictureFile}
square
description="Upload a square image for best results. Supported formats: JPG, PNG, WebP, AVIF. Max size: 5MB."
/>
<InputCardGroup
name="type"
label="Type"
options={[
{
label: 'Admin',
value: 'admin',
icon: 'ri:shield-star-fill',
noTransitionPersist: true,
},
{
label: 'Moderator',
value: 'moderator',
icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
},
{
label: 'Spammer',
value: 'spammer',
icon: 'ri:alert-fill',
noTransitionPersist: true,
},
{
label: 'Verified',
value: 'verified',
icon: 'ri:verified-badge-fill',
disabled: true,
noTransitionPersist: true,
},
]}
selectedValue={[
user.admin ? 'admin' : null,
user.verified ? 'verified' : null,
user.moderator ? 'moderator' : null,
user.spammer ? 'spammer' : null,
].filter((v) => v !== null)}
required
cardSize="sm"
iconSize="sm"
multiple
error={updateInputErrors.type}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</FormSection>
<FormSection title="Internal Notes">
{
user.internalNotes.length === 0 ? (
<p class="text-day-300 text-center">No internal notes yet.</p>
) : (
<div class="space-y-4">
{user.internalNotes.map((note) => (
<div data-note-id={note.id} class="border-night-400 bg-night-600 rounded-lg border p-4 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-4">
<div class="flex items-center gap-1">
{!!note.addedByUser?.picture && (
<MyPicture
src={note.addedByUser.picture}
alt=""
width={12}
height={12}
class="size-6 rounded-full"
/>
)}
<span class="text-day-100 font-medium">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{note.addedByUser ? note.addedByUser.displayName || note.addedByUser.name : 'System'}
</span>
<TimeFormatted date={note.createdAt} hourPrecision class="text-day-300 ms-1 text-sm" />
</div>
<div class="flex items-center">
<label class="text-day-300 hover:text-day-100 cursor-pointer p-1 transition-colors">
<input
type="checkbox"
class="peer sr-only"
data-edit-note-checkbox
data-note-id={note.id}
/>
<Icon name="ri:edit-line" class="size-5" />
</label>
<form
method="POST"
action={actions.admin.user.internalNotes.delete}
class="space-y-2"
data-astro-reload
>
<input type="hidden" name="noteId" value={note.id} />
<button type="submit" class="text-day-300 p-1 transition-colors hover:text-red-400">
<Icon name="ri:delete-bin-line" class="size-5" />
</button>
</form>
</div>
</div>
<div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
<Markdown content={note.content} />
</div>
<form
method="POST"
action={actions.admin.user.internalNotes.update}
data-note-edit-form
data-astro-reload
class="mt-4 hidden space-y-4"
>
<input type="hidden" name="noteId" value={note.id} />
<InputTextArea
label="Note Content"
name="content"
value={note.content}
inputProps={{ class: 'bg-night-700' }}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.user.internalNotes.add}
class="mt-10 space-y-2"
data-astro-reload
>
<h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Note</h3>
<input type="hidden" name="userId" value={user.id} />
<InputTextArea
label="Note Content"
name="content"
inputProps={{ placeholder: 'Add a note...', rows: 3 }}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form>
</FormSection>
<FormSection title="Service Affiliations">
{
user.serviceAffiliations.length === 0 ? (
<p class="text-day-200 text-center">No service affiliations yet.</p>
) : (
<div class="grid grid-cols-2 gap-x-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<div class="bg-night-600 border-night-400 flex items-center justify-start gap-2 rounded-lg border px-3 py-2">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-400 text-sm">at</span>
<a
href={`/service/${affiliation.service.slug}`}
class="text-day-100 hover:text-day-50 flex items-center gap-1 leading-none font-medium hover:underline"
>
<span>{affiliation.service.name}</span>
<Icon name="ri:external-link-line" class="text-day-400 size-3.5" />
</a>
<TimeFormatted
date={affiliation.createdAt}
hourPrecision
class="text-day-400 ms-auto me-2 text-sm"
/>
<form
method="POST"
action={actions.admin.user.serviceAffiliations.remove}
data-astro-reload
class="space-y-2"
>
<input type="hidden" name="id" value={affiliation.id} />
<button type="submit" class="text-day-300 transition-colors hover:text-red-400">
<Icon name="ri:delete-bin-line" class="size-5" />
</button>
</form>
</div>
)
})}
</div>
)
}
<form
method="POST"
action={actions.admin.user.serviceAffiliations.add}
data-astro-reload
class="mt-10 space-y-2"
>
<h3 class="font-title mb-0 text-center text-xl leading-none font-bold">Add Affiliation</h3>
<input type="hidden" name="userId" value={user.id} />
<InputSelect
name="serviceId"
label="Service"
options={allServices.map((service) => ({
label: service.name,
value: service.id.toString(),
}))}
selectProps={{ required: true }}
/>
<InputCardGroup
name="role"
label="Role"
options={serviceUserRoles.map((role) => ({
label: role.label,
value: role.value,
icon: role.icon,
noTransitionPersist: true,
}))}
required
cardSize="sm"
iconSize="sm"
/>
<InputSubmitButton label="Add Affiliation" icon="ri:link" hideCancel />
</form>
</FormSection>
<FormSection title="Grant/Remove Karma">
<form method="POST" action={actions.admin.user.karmaTransactions.add} data-astro-reload class="space-y-2">
<input type="hidden" name="userId" value={user.id} />
<InputText
label="Points"
name="points"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ type: 'number', required: true }}
/>
<InputTextArea
label="Description"
name="description"
error={addKarmaTransactionResult?.error?.message}
inputProps={{ required: true }}
/>
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form>
</FormSection>
</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]')
if (noteContent && editForm) {
if (target.checked) {
noteContent.classList.add('hidden')
editForm.classList.remove('hidden')
} else {
noteContent.classList.remove('hidden')
editForm.classList.add('hidden')
}
}
})
})
})
</script>