497 lines
16 KiB
Plaintext
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>
|