Adds edition of slug.

This commit is contained in:
2025-04-16 15:26:29 -04:00
parent 41aeb81a00
commit 887f6f255a
6 changed files with 328 additions and 66 deletions

View File

@@ -0,0 +1,49 @@
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeNameRequest(
Guid CreatorId,
string Name);
[PublicAPI]
internal sealed class ChangeNameRequestValidator
: Validator<ChangeNameRequest>
{
public ChangeNameRequestValidator()
{
RuleFor(r => r.Name)
.NotNull().WithMessage("You should specify the Name")
.NotEmpty().WithMessage("You should specify a valid/not empty Name");
}
}
[PublicAPI]
public class ChangeNameHandler(
ContentDbContext context)
: Endpoint<ChangeNameRequest>
{
public override void Configure()
{
Post("/api/creators/{CreatorId}/name");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeNameRequest request,
CancellationToken ct)
{
var creator = await context
.Creators
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
creator.Name = request.Name;
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
}

View File

@@ -0,0 +1,97 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
[PublicAPI]
public record ChangeSlugRequest(
Guid CreatorId,
Guid SlugReservationId);
[PublicAPI]
internal sealed class ChangeSlugRequestValidator
: Validator<ChangeSlugRequest>
{
public ChangeSlugRequestValidator()
{
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.SlugReservationId)
.NotNull().WithMessage("You should specify the SlugReservationId")
.NotEmpty().WithMessage("You should specify a valid/not empty SlugReservationId");
}
}
[PublicAPI]
public class ChangeSlugHandler(
ContentDbContext context)
: Endpoint<ChangeSlugRequest>
{
public override void Configure()
{
Put("/api/creators/{CreatorId}/slug");
Options(o => o.WithTags("Creators"));
}
public override async Task HandleAsync(
ChangeSlugRequest request,
CancellationToken ct)
{
await using var transaction = await context.Database.BeginTransactionAsync(ct);
try
{
var creator = await context
.Creators
.SingleAsync(
c => c.Id == request.CreatorId,
cancellationToken: ct);
if (creator.CreatedBy != User.GetUserId())
{
await SendUnauthorizedAsync(ct);
return;
}
var reservation = await context
.Slugs
.FirstOrDefaultAsync(
s => s.Id == request.SlugReservationId,
ct);
if (reservation is null)
{
await SendNotFoundAsync(ct);
return;
}
var previousReservation = await context
.Slugs
.FirstOrDefaultAsync(
s => s.UsedBy == request.CreatorId,
ct);
if (previousReservation is null)
{
await SendErrorsAsync(cancellation: ct);
return;
}
context.Remove(previousReservation);
reservation.UsedBy = creator.Id;
creator.Slug = reservation.NormalizedName;
await context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await SendOkAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
}
}
}

View File

@@ -1,78 +1,24 @@
<template> <template>
<div class="relative"> <div v-show="brandingStore.value.verified"
class="text-blue m-4">
<icon-account-verified></icon-account-verified>
</div>
<div class="relative flex flex-row" <div class="flex flex-col text-hOnPrimary">
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()"
>
<div v-show="brandingStore.value.verified"
class="text-blue m-4">
<icon-account-verified></icon-account-verified>
</div>
<div class="flex flex-col text-hOnPrimary">
<span class="capitalize text-3xl"> <span class="capitalize text-3xl">
{{ brandingStore.value.name }} {{ brandingStore.value.name }}
</span> </span>
<span class="capitalize text-lg"> <span class="capitalize text-lg">
{{ brandingStore.value.title }} {{ brandingStore.value.title }}
</span> </span>
</div> </div>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute inset-0 bg-black/25 cursor-pointer"
>
<!-- Top-right Icon -->
<div
class="absolute top-1 right-1 w-12 h-12 bg-white rounded-full flex items-center justify-center shadow-lg"
>
<v-icon large>mdi-pencil</v-icon>
</div>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<div class="bg-white rounded-2xl p-4">
<name-title-editor
:creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"
></name-title-editor>
</div>
</template>
</v-dialog>
</template> </template>
<script setup> <script setup>
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue"; import IconAccountVerified from "@/components/icons/IconAccountVerified.vue";
import {useBrandingStore} from "@/stores/brandingStore.js"; import {useBrandingStore} from "@/stores/brandingStore.js";
import {useAuthStore} from "@/stores/authStore.js";
import {computed, ref} from "vue";
import NameTitleEditor from "@/views/creators/NameTitleEditor.vue";
const authStore = useAuthStore();
const brandingStore = useBrandingStore(); const brandingStore = useBrandingStore();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script> </script>

View File

@@ -7,6 +7,8 @@ import AliasDialog from "@/views/profile/account/AliasDialog.vue";
import FullnameDialog from "@/views/profile/account/FullnameDialog.vue"; import FullnameDialog from "@/views/profile/account/FullnameDialog.vue";
import EmailDialog from "@/views/profile/account/EmailDialog.vue"; import EmailDialog from "@/views/profile/account/EmailDialog.vue";
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue'; import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
import ChangeTitleDialog from '@/views/profile/creators/ChangeTitleDialog.vue'; import ChangeTitleDialog from '@/views/profile/creators/ChangeTitleDialog.vue';
import Youtube from "@/views/svg/Youtube.vue"; import Youtube from "@/views/svg/Youtube.vue";
import Web from "@/views/svg/Web.vue"; import Web from "@/views/svg/Web.vue";
@@ -75,6 +77,8 @@ const currentComponent = ref('');
const componentsMap = { const componentsMap = {
EmailDialog, EmailDialog,
SocialsDialog, SocialsDialog,
ChangeSlugDialog,
ChangeNameDialog,
ChangeTitleDialog, ChangeTitleDialog,
ChangeStripeIdDialog, ChangeStripeIdDialog,
}; };
@@ -174,15 +178,15 @@ const closeDialog = () => {
<div class="content"> <div class="content">
<!-- NAME --> <!-- NAME -->
<button class="action"> <button class="action" @click="openDialog('ChangeNameDialog')">
<span class="label">{{ $t('creatorinfopage.name') }}</span> <span class="label">{{ $t('creatorinfopage.name') }}</span>
<span class="value">{{ creatorProfileStore.creator.name }}</span> <span class="value">{{ creatorProfileStore.creator.name }}</span>
<span class="chevron"><v-icon>mdi-chevron-right</v-icon></span> <span class="chevron"><v-icon>mdi-chevron-right</v-icon></span>
</button> </button>
<button class="action"> <button class="action" @click="openDialog('ChangeSlugDialog')">
<span class="label">{{ $t('creatorinfopage.slug') }}</span> <span class="label">{{ $t('creatorinfopage.slug') }}</span>
<span class="value">{{ creatorProfileStore.creator.slug }}</span> <span class="value">@{{ creatorProfileStore.creator.slug }}</span>
<span class="chevron"><v-icon>mdi-chevron-right</v-icon></span> <span class="chevron"><v-icon>mdi-chevron-right</v-icon></span>
</button> </button>
@@ -332,7 +336,7 @@ const closeDialog = () => {
} }
.value { .value {
@apply flex-auto text-left pr-6 capitalize; @apply flex-auto text-left pr-6;
@apply break-words overflow-auto; @apply break-words overflow-auto;
} }

View File

@@ -0,0 +1,69 @@
<script setup>
import {ref} from 'vue';
import {useClient} from '@/plugins/api.js';
const props = defineProps({
creator: {
required: true
}
});
const emits = defineEmits(['closeRequested']);
const name = ref(props.creator.name);
const client = useClient();
async function save() {
try {
await client.post(
`/api/creators/${props.creator.id}/name`,
{
name: name.value
}
);
props.creator.name = name.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
}
}
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card-title">
Modifier le Nom
</div>
<div class="card-content">
<v-text-field
v-model="name"
label="Name"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
Annuler
</button>
<button class="primary"
@click="save">
Enregistrer
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
import {computed, ref} from 'vue';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useClient } from "@/plugins/api.js";
import NameEditor from "@/views/creators/NameEditor.vue";
const props = defineProps({
creator: {
required: true
}
});
const emit = defineEmits(['closeRequested']);
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const newSlug = ref('');
const slugReservationId = ref(undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const canSave = computed(() => slugReservationId.value !== undefined);
function handleSlugReservationIdChanged($event) {
slugReservationId.value = $event;
}
async function save() {
try {
isOperationPending.value = true;
errorMessage.value = '';
await client.put(`/api/creators/${props.creator.id}/slug`, {
slugReservationId: slugReservationId.value
});
await creatorProfileStore.fetchCreatorProfile();
emit('closeRequested');
} catch (error) {
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || 'An unexpected error occurred.';
} else {
errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.';
}
} finally {
isOperationPending.value = false;
}
}
const cancel = () => {
emit('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card-title">
Modifier l'URL
</div>
<div class="card-content">
<p class="mb-4">
L'adresse actuelle de votre page est : <strong>@{{ creator.slug }}</strong>
</p>
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
></name-editor>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button class="secondary"
@click="cancel">
Annuler
</button>
<button class="primary"
@click="save"
:disabled="!canSave || isOperationPending">
Enregistrer
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>