Merge branch 'community'
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

# Conflicts:
#	frontend/src/views/main/Footer.vue
#	frontend/src/views/main/Landing.vue
This commit is contained in:
2025-12-08 16:11:19 -05:00
44 changed files with 4194 additions and 4420 deletions

View File

@@ -1,57 +1,53 @@
<template>
<v-app>
<div class="shell-container">
<v-app>
<div class="shell-container">
<div class="shell-side">
<site-bar></site-bar>
</div>
<div class="shell-side">
<side-bar></side-bar>
</div>
<div class="shell-view">
<router-view></router-view>
</div>
</div>
</v-app>
<div class="shell-view">
<router-view></router-view>
</div>
</div>
</v-app>
</template>
<script async setup>
import { mdiFileAccountOutline } from '@mdi/js';
import SideBar from "@/views/main/SideBar.vue";
import { useLanguageStore } from "@/stores/languageStore.js";
import { watch } from "vue";
import { useI18n } from "vue-i18n";
import SiteBar from '@/views/main/SiteBar.vue';
import { useLanguageStore } from '@/stores/languageStore.js';
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
// Watch for language changes and update i18n locale
const languageStore = useLanguageStore();
const { locale } = useI18n();
// Watch for changes to the language store
watch(() => languageStore.locale, (newLocale) => {
if (newLocale) {
locale.value = newLocale;
}
}, { immediate: true });
// Watch for language changes and update i18n locale
const languageStore = useLanguageStore();
const { locale } = useI18n();
// Watch for changes to the language store
watch(
() => languageStore.locale,
newLocale => {
if (newLocale) {
locale.value = newLocale;
}
},
{ immediate: true }
);
</script>
<style scoped>
.shell-container {
@apply flex flex-col lg:flex-row;
@apply font-sans;
@apply bg-hBackground text-hOnBackground;
@apply min-h-screen h-full;
}
.shell-container {
@apply flex flex-col;
@apply w-full;
@apply font-sans;
@apply bg-hBackground text-hOnBackground;
}
.shell-side {
@apply lg:fixed lg:max-h-screen;
@apply flex-shrink-0;
}
.shell-side {
@apply flex-shrink-0;
}
.shell-view {
@apply flex-grow;
@apply flex justify-center items-center;
@apply w-full;
@apply lg:ml-64;
}
.shell-view {
@apply flex-grow;
@apply flex justify-center items-center;
}
</style>

View File

@@ -1,49 +0,0 @@
.banner-image {
margin: 0 auto;
width: 100%; /* Augmenter la largeur de l'image */
max-height: 40vh; /* Réduire légèrement la hauteur de l'image */
object-fit: cover;
object-position: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Ajouter une ombre à l'image */
}
.banner-images-row {
display: flex;
justify-content: space-between;
gap: 0; /* Supprime l'espacement entre les éléments */
}
.banner-image-item-wrapper {
width: 76%; /* Définit la largeur du conteneur à 20% de la largeur de l'écran */
height: auto; /* Garde la hauteur d'origine */
}
.banner-image-item {
width: 100%; /* Définit la largeur de l'image à 100% du conteneur */
height: 300x; /* Hauteur fixe pour l'exemple, à ajuster selon vos besoins */
object-fit: cover; /* Coupe l'image pour remplir le conteneur tout en conservant les proportions */
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: white;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 5px;
display: none;
}
.banner-image-item:hover .dropdown-menu {
display: block;
}
.custom-container {
width: 40%;
}

View File

@@ -1,112 +0,0 @@
/* CSS pour ajuster la taille de l'image */
.img-small {
width: 70px;
height: 70px;
}
.img-Logo {
width: 200px;
height: 70px;
}
.text-custom {
color: #000000;
/* Couleur de texte spécifique */
}
/* CSS pour le texte du menu-left */
.menu-left a {
color: #000000;
/* Couleur du texte */
font-weight: bold;
/* Gras */
text-decoration: none;
/* Pas de soulignement */
font-size: 24px;
/* Taille de la police en pixels */
}
/* CSS pour le texte des liens du menu-center */
.menu-center {
display: flex;
justify-content: center;
/* Centrer les éléments horizontalement */
align-items: center;
/* Centrer les éléments verticalement */
flex: 1;
/* Utiliser tout l'espace disponible */
margin-right: 8%
}
/* CSS pour le texte du menu-right */
.menu-right a {
color: #e4e4e4;
/* Couleur du texte */
}
.bg-custom {
background-color: #ffffff;
/* Définissez la couleur de fond souhaitée */
}
.textLogo {
font-size: 35px;
/* Taille de la police en pixels */
}
.logo {
margin-right: 5px;
/* Réduire la marge entre le logo et le texte */
}
.profilePicture {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
/* Ajouter une ombre à la photo */
border: 2px solid #a30e79;
/* Ajouter une bordure de 2px solide de couleur rouge (#f00) */
}
.bg-customdarker {
background-color: #ffffffa4;
/* Définissez la couleur de fond souhaitée */
}
.top-aligned-column {
display: flex;
justify-content: space-between;
}
.column {
flex: 1;
}
.center-column {
flex: 3;
/* La colonne centrale occupe 3 fois plus d'espace que les autres */
}
.colum-aligncenter {
text-align: center;
}
.menu-center a:nth-child(1):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en rouge au survol */
}
/* Pour le deuxième bouton */
.menu-center a:nth-child(2):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en bleu au survol */
}
/* Pour le troisième bouton */
.menu-center a:nth-child(3):hover svg {
color: rgba(163, 14, 121, 1);
/* Changer la couleur en vert au survol */
}

View File

@@ -1,41 +0,0 @@
{
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"create": "Crear",
"apply": "Aplicar",
"preview": "Vista previa",
"label": "Etiqueta",
"confirm": "Confirmar",
"close": "Cerrar",
"accept": "Aceptar",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"changesSaved": "Cambios guardados con éxito",
"errorOccurred": "Ha ocurrido un error",
"name": "Nombre",
"email": "Correo electrónico",
"password": "Contraseña",
"description": "Descripción",
"title": "Título",
"image": "Imagen",
"file": "Archivo",
"required": "Este campo es obligatorio",
"invalidEmail": "Correo electrónico inválido",
"invalidPassword": "Contraseña inválida",
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Sitio web",
"errors": {
"unexpected": "Ha ocurrido un error inesperado",
"imageLoad": "Error al cargar la imagen",
"imageUpload": "Error al subir la imagen"
}
}

View File

@@ -5,54 +5,71 @@ import { createPinia } from 'pinia';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import { VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert } from 'vuetify/components';
import { } from 'vuetify/directives';
import {
VAlert,
VApp,
VBtn,
VDialog,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VSnackbar,
VTextarea,
VTextField,
} from 'vuetify/components';
import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from "@/stores/authStore.js";
import { useUserProfileStore } from "@/stores/userProfileStore.js";
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
import { useAuthStore } from '@/stores/authStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css';
import './assets/main.css';
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
const vuetify = createVuetify({
components: {
VDialog, VApp, VBtn, VProgressLinear, VProgressCircular, VIcon, VTextField, VSnackbar, VForm, VTextarea, VAlert
},
directives: {
},
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi }
}
components: {
VDialog,
VApp,
VBtn,
VProgressLinear,
VProgressCircular,
VIcon,
VTextField,
VSnackbar,
VForm,
VTextarea,
VAlert,
},
directives: {},
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
});
import { createI18n } from 'vue-i18n'
import en from '@/locales/en.json'
import fr from '@/locales/fr.json'
import es from '@/locales/es.json'
const i18n = createI18n({
legacy: false,
fallbackLocale: 'en',
messages: {
en: en,
fr: fr,
es: es
}
})
legacy: false,
fallbackLocale: 'fr',
messages: {
en: en,
fr: fr,
},
});
const pinia = createPinia();
const app = createApp(App)
.use(pinia)
.use(vuetify)
.use(router)
.use(i18n)
.use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
})
.use(pinia)
.use(vuetify)
.use(router)
.use(i18n)
.use(vueGoogleOauth, {
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
})
.use(Toast, {
position: POSITION.TOP_CENTER,
});

View File

@@ -1,31 +1,38 @@
import { defineStore } from 'pinia'
import { useSessionStorage } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
export const useLanguageStore = defineStore(
'language',
() => {
// Initialize with the stored value or default to 'fr'
const storedLocale = useSessionStorage('user-locale', 'fr')
// Get i18n instance
const { locale } = useI18n()
// Set the initial locale from storage
if (locale && storedLocale.value) {
locale.value = storedLocale.value
}
function setLocale(newLocale) {
if (locale) {
locale.value = newLocale
}
storedLocale.value = newLocale
}
return {
locale: storedLocale,
setLocale
}
const ALLOWED_LOCALES = ['en', 'fr'];
const DEFAULT_LOCALE = 'fr';
export const useLanguageStore = defineStore('language', () => {
const storedLocale = useSessionStorage('user-locale', DEFAULT_LOCALE);
// Get i18n instance (provided globally)
const { locale } = useI18n();
function sanitizeLocale(value) {
return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE;
}
)
// Initialize locale with a sanitized value
const initial = sanitizeLocale(storedLocale.value);
storedLocale.value = initial;
if (locale) {
locale.value = initial;
}
function setLocale(newLocale) {
const next = sanitizeLocale(newLocale);
if (locale) {
locale.value = next;
}
storedLocale.value = next;
}
return {
locale: storedLocale,
setLocale,
allowedLocales: ALLOWED_LOCALES,
};
});

View File

@@ -1,209 +1,218 @@
<template>
<div class="flex min-h-full justify-center items-center w-full p-4">
<div class="flex flex-col gap-10 w-full max-w-[512px]">
<h1 class="text-2xl font-bold text-center">
{{ t('title') }}
</h1>
<div class="flex min-h-full justify-center items-center w-full p-4">
<div class="flex flex-col gap-10 w-full max-w-[512px]">
<h1 class="text-2xl font-bold text-center">
{{ t('title') }}
</h1>
<p class="text-center text-hOnSurface">
{{ t('description') }}
</p>
<p class="text-center text-hOnSurface">
{{ t('description') }}
</p>
<div class="card">
<form @submit.prevent="handleForgotPassword">
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label for="email" class="form-label">{{ t('email') }}</label>
<input
id="email"
v-model="email"
type="email"
class="form-input"
required
/>
</div>
<div class="card">
<form @submit.prevent="handleForgotPassword">
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="email"
>
{{ t('email') }}
</label>
<input
id="email"
v-model="email"
class="form-input"
required
type="email"
/>
</div>
<button
type="submit"
class="primary w-full"
:disabled="isLoading"
>
<span v-if="isLoading" class="loading-spinner mr-2"></span>
{{ t('resetPassword') }}
</button>
<button
:disabled="isLoading"
class="primary w-full"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
<div class="text-center mt-4">
<router-link to="/login" class="text-sm text-blue-500">
{{ t('backToLogin') }}
</router-link>
</div>
<div class="text-center mt-4">
<router-link
class="text-sm text-blue-500"
to="/login"
>
{{ t('backToLogin') }}
</router-link>
</div>
</div>
</div>
</form>
</div>
</div>
</form>
</div>
<!-- Success message -->
<div v-if="showSuccessMessage" class="notification success">
{{ t('resetEmailSent') }}
</div>
<!-- Error message -->
<div v-if="showErrorMessage" class="notification error">
{{ errorMessage }}
</div>
<!-- Success message -->
<div
v-if="showSuccessMessage"
class="notification success"
>
{{ t('resetEmailSent') }}
</div>
<!-- Error message -->
<div
v-if="showErrorMessage"
class="notification error"
>
{{ errorMessage }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useI18n} from 'vue-i18n';
import {useRouter} from 'vue-router';
import {useClient} from '@/plugins/api.js';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
const {t} = useI18n();
const router = useRouter();
const clientApi = useClient();
const { t } = useI18n();
const router = useRouter();
const clientApi = useClient();
const email = ref('');
const isLoading = ref(false);
const showSuccessMessage = ref(false);
const showErrorMessage = ref(false);
const errorMessage = ref('');
const email = ref('');
const isLoading = ref(false);
const showSuccessMessage = ref(false);
const showErrorMessage = ref(false);
const errorMessage = ref('');
async function handleForgotPassword() {
// Reset notification states
showSuccessMessage.value = false;
showErrorMessage.value = false;
async function handleForgotPassword() {
// Reset notification states
showSuccessMessage.value = false;
showErrorMessage.value = false;
if (!email.value) {
errorMessage.value = t('emailRequired');
showErrorMessage.value = true;
return;
}
if (!email.value) {
errorMessage.value = t('emailRequired');
showErrorMessage.value = true;
return;
}
isLoading.value = true;
isLoading.value = true;
try {
// Call password reset API
await clientApi.post('api/users/forgot-password', {
email: email.value.trim()
});
try {
// Call password reset API
await clientApi.post('api/users/forgot-password', {
email: email.value.trim(),
});
// Show success message
showSuccessMessage.value = true;
// Show success message
showSuccessMessage.value = true;
// Clear the form
email.value = '';
// Clear the form
email.value = '';
// Redirect to login after a short delay
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (error) {
console.error('Password reset request failed:', error);
errorMessage.value = error.response?.data?.message || t('resetRequestFailed');
showErrorMessage.value = true;
} finally {
isLoading.value = false;
}
}
// Redirect to login after a short delay
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (error) {
console.error('Password reset request failed:', error);
errorMessage.value = error.response?.data?.message || t('resetRequestFailed');
showErrorMessage.value = true;
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.card-content {
@apply p-6;
}
.card-content {
@apply p-6;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg
focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5
dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg
focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5
dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5
focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5
focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.notification {
@apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm;
animation: fade-in 0.3s ease-in, fade-out 0.3s ease-out 5s forwards;
}
.notification {
@apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm;
animation:
fade-in 0.3s ease-in,
fade-out 0.3s ease-out 5s forwards;
}
.success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
.success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
.error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
<i18n>
{
"en": {
"title": "Forgot Password?",
"description": "Please enter your account email address. A password reset link will be sent to you.",
"email": "Email",
"resetPassword": "Reset Password",
"backToLogin": "Back to Login",
"resetEmailSent": "Password reset email sent. Please check your inbox.",
"resetRequestFailed": "Failed to request password reset. Please try again.",
"emailRequired": "Email is required."
},
"fr": {
"title": "Mot de passe oublié ?",
"description": "Veuillez saisir l'adresse e-mail de votre compte. Un lien de réinitialisation vous sera envoyé.",
"email": "Email",
"resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion",
"resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.",
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
"emailRequired": "L'email est requis."
},
"es": {
"title": "¿Olvidaste tu contraseña?",
"description": "Por favor, introduce la dirección de correo electrónico de tu cuenta. Te enviaremos un enlace para restablecer tu contraseña.",
"email": "Correo electrónico",
"resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión",
"resetEmailSent": "Correo electrónico de restablecimiento de contraseña enviado. Por favor revise su bandeja de entrada.",
"resetRequestFailed": "No se pudo solicitar el restablecimiento de contraseña. Por favor, inténtelo de nuevo.",
"emailRequired": "El correo electrónico es obligatorio."
}
"en": {
"title": "Forgot Password?",
"description": "Please enter your account email address. A password reset link will be sent to you.",
"email": "Email",
"resetPassword": "Reset Password",
"backToLogin": "Back to Login",
"resetEmailSent": "Password reset email sent. Please check your inbox.",
"resetRequestFailed": "Failed to request password reset. Please try again.",
"emailRequired": "Email is required."
},
"fr": {
"title": "Mot de passe oublié ?",
"description": "Veuillez saisir l'adresse e-mail de votre compte. Un lien de réinitialisation vous sera envoyé.",
"email": "Email",
"resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion",
"resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.",
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
"emailRequired": "L'email est requis."
}
}
</i18n>

View File

@@ -1,195 +1,214 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex flex-col gap-4">
<google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</google-login>
</div>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<div class="flex flex-col gap-4">
<google-login :callback="googleCallback" popup-type="TOKEN">
<button class="secondary">
<v-icon class="mr-2" :icon="mdiGoogle" />
{{ t('continueWithGoogle') }}
</button>
</google-login>
</div>
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4">
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<v-text-field
v-model="password"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4">
<v-text-field v-model="email" :label="t('email')" type="email" required></v-text-field>
<v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }}
</v-btn>
<v-text-field v-model="password" :label="t('password')" :type="showPassword ? 'text' : 'password'" required>
<template v-slot:append-inner>
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small"
:icon="showPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<div class="text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="forgotPassword"
>
{{ t('forgotPassword') }}
</a>
</div>
<v-btn type="submit" color="primary" block>
{{ t('signIn') }}
</v-btn>
<div class="mt-2 text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }}
</a>
</div>
<div class="text-center">
<a @click="forgotPassword" class="cursor-pointer text-sm text-blue-500">
{{ t('forgotPassword') }}
</a>
</div>
<div class="mt-2 text-center">
<a @click="resendVerification" class="cursor-pointer text-sm text-blue-500">
{{ t('resendVerification') }}
</a>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link to="/register" class="text-blue-500">
{{ t('register') }}
</router-link>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }}
</router-link>
</div>
</div>
</v-form>
</div>
</v-form>
<!-- Error notification -->
<v-snackbar
v-model="errorSnackBar"
color="error"
>
{{ t('loginFailed') }}
</v-snackbar>
</div>
<!-- Error notification -->
<v-snackbar v-model="errorSnackBar" color="error">
{{ t('loginFailed') }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { GoogleLogin } from "vue3-google-login";
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiGoogle, mdiEye, mdiEyeOff } from '@mdi/js';
import { ref } from 'vue';
import { GoogleLogin } from 'vue3-google-login';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const email = ref('');
const password = ref('');
const errorSnackBar = ref(false);
const showPassword = ref(false);
const email = ref('');
const password = ref('');
const errorSnackBar = ref(false);
const showPassword = ref(false);
const props = defineProps({
returnUrl: {
type: String,
default: '/landing'
}
});
const props = defineProps({
returnUrl: {
type: String,
default: '/landing',
},
});
async function handleLocalLogin() {
try {
await authStore.login(email.value, password.value);
await router.push(props.returnUrl);
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
async function googleCallback(token) {
try {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
async function handleLocalLogin() {
try {
await authStore.login(email.value, password.value);
await router.push(props.returnUrl);
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
function forgotPassword() {
router.push('/forgot-password');
}
async function googleCallback(token) {
try {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
}
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
function resendVerification() {
router.push('/verify-email');
}
function forgotPassword() {
router.push('/forgot-password');
}
function resendVerification() {
router.push('/verify-email');
}
</script>
<style scoped>
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
/* Dark mode support if needed */
@media (prefers-color-scheme: dark) {
.custom-divider {
background-color: rgb(75, 85, 99);
/* Equivalent to gray-600 */
}
}
/* Dark mode support if needed */
@media (prefers-color-scheme: dark) {
.custom-divider {
background-color: rgb(75, 85, 99);
/* Equivalent to gray-600 */
}
}
</style>
<i18n>
{
"en": {
"title": "Sign in",
"alt": "Login",
"email": "Email",
"password": "Password",
"signIn": "Connect",
"forgotPassword": "Forgot password?",
"resendVerification": "Resend verification email",
"orContinueWith": "Or",
"noAccount": "Don't have an account?",
"register": "Register",
"loginFailed": "Login failed. Please check your credentials.",
"continueWithGoogle": "Continue with Google"
},
"fr": {
"title": "Se connecter",
"alt": "Connexion",
"email": "Email",
"password": "Mot de passe",
"signIn": "Connexion",
"forgotPassword": "Mot de passe oublié?",
"resendVerification": "Renvoyer l'email de vérification",
"orContinueWith": "Ou",
"noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"continueWithGoogle": "Continuer avec Google"
},
"es": {
"title": "Iniciar sesión",
"alt": "Inicio de sesión",
"email": "Correo electrónico",
"password": "Contraseña",
"signIn": "Conéctate",
"forgotPassword": "¿Olvidó su contraseña?",
"resendVerification": "Reenviar correo de verificación",
"orContinueWith": "o",
"noAccount": "¿No tiene una cuenta?",
"register": "Registrarse",
"loginFailed": "Error de inicio de sesión. Por favor, compruebe sus credenciales.",
"continueWithGoogle": "Continuar con Google"
}
"en": {
"title": "Sign in",
"alt": "Login",
"email": "Email",
"password": "Password",
"signIn": "Connect",
"forgotPassword": "Forgot password?",
"resendVerification": "Resend verification email",
"orContinueWith": "Or",
"noAccount": "Don't have an account?",
"register": "Register",
"loginFailed": "Login failed. Please check your credentials.",
"continueWithGoogle": "Continue with Google"
},
"fr": {
"title": "Se connecter",
"alt": "Connexion",
"email": "Email",
"password": "Mot de passe",
"signIn": "Connexion",
"forgotPassword": "Mot de passe oublié?",
"resendVerification": "Renvoyer l'email de vérification",
"orContinueWith": "Ou",
"noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"continueWithGoogle": "Continuer avec Google"
}
}
</i18n>

View File

@@ -25,8 +25,8 @@
{{ t('success.backToLogin') }}
</router-link>
<router-link
class="text-blue-500 hover:underline"
:to="{ path: '/verify-email', query: { email: userEmail } }"
class="text-blue-500 hover:underline"
>
{{ t('success.resendVerification') }}
</router-link>
@@ -232,26 +232,6 @@
"backToLogin": "Retour à la connexion",
"resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification"
}
},
"es": {
"title": "Crea tu cuenta",
"alt": "Registro de Hutopy",
"name": "Nombre completo",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"register": "Registrarse",
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"signIn": "Iniciar sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"registrationFailed": "El registro falló. Por favor, inténtelo de nuevo.",
"success": {
"title": "¡Registro exitoso!",
"message": "Por favor revisa tu correo electrónico para verificar tu cuenta. Hemos enviado un enlace de verificación a:",
"backToLogin": "Volver al inicio de sesión",
"resendVerification": "¿No recibiste el correo? Reenviar verificación"
}
}
}
</i18n>

View File

@@ -259,19 +259,6 @@
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
},
"es": {
"title": "Restablecer su Contraseña",
"newPassword": "Nueva Contraseña",
"confirmPassword": "Confirmar Contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"resetPassword": "Restablecer Contraseña",
"passwordResetSuccess": "¡Su contraseña ha sido restablecida con éxito!",
"proceedToLogin": "Proceder al Inicio de Sesión",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"resetFailed": "Error al restablecer la contraseña. Inténtelo de nuevo o solicite un nuevo enlace de restablecimiento.",
"invalidResetLink": "Enlace de restablecimiento inválido o caducado. Solicite un nuevo restablecimiento de contraseña."
}
}
</i18n>

View File

@@ -1,219 +1,237 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10 text-center">
<!-- Loading state while verification is in progress -->
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
</div>
<!-- Success state -->
<div v-else-if="verificationSuccess" class="flex flex-col items-center gap-6">
<v-icon icon="mdi-check-circle" color="green" size="64"></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<p>{{ t('success.message') }}</p>
<v-btn color="primary" @click="goToLogin">{{ t('success.goToLogin') }}</v-btn>
</div>
<!-- Error state -->
<div v-else class="flex flex-col items-center gap-6">
<v-icon icon="mdi-alert-circle" color="error" size="64"></v-icon>
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<div class="mt-4 flex flex-col gap-4 w-full">
<v-btn color="primary" @click="goToLogin">{{ t('error.goToLogin') }}</v-btn>
<v-divider class="my-4"></v-divider>
<!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form @submit.prevent="handleResendVerification" class="w-full">
<div class="flex flex-col gap-4">
<v-text-field
v-model="resendEmail"
:label="t('resend.emailLabel')"
type="email"
required
:error-messages="resendEmailError"
></v-text-field>
<v-btn
type="submit"
color="secondary"
block
:loading="resendLoading"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div v-if="resendSuccess" class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div v-if="resendError" class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
{{ resendError }}
</div>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10 text-center">
<!-- Loading state while verification is in progress -->
<div
v-if="isLoading"
class="flex flex-col items-center gap-4"
>
<v-progress-circular
color="primary"
indeterminate
size="64"
></v-progress-circular>
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
</div>
<!-- Success state -->
<div
v-else-if="verificationSuccess"
class="flex flex-col items-center gap-6"
>
<v-icon
color="green"
icon="mdi-check-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<p>{{ t('success.message') }}</p>
<v-btn
color="primary"
@click="goToLogin"
>
{{ t('success.goToLogin') }}
</v-btn>
</div>
<!-- Error state -->
<div
v-else
class="flex flex-col items-center gap-6"
>
<v-icon
color="error"
icon="mdi-alert-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<div class="mt-4 flex flex-col gap-4 w-full">
<v-btn
color="primary"
@click="goToLogin"
>
{{ t('error.goToLogin') }}
</v-btn>
<v-divider class="my-4"></v-divider>
<!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form
class="w-full"
@submit.prevent="handleResendVerification"
>
<div class="flex flex-col gap-4">
<v-text-field
v-model="resendEmail"
:error-messages="resendEmailError"
:label="t('resend.emailLabel')"
required
type="email"
></v-text-field>
<v-btn
:loading="resendLoading"
block
color="secondary"
type="submit"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div
v-if="resendSuccess"
class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"
>
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div
v-if="resendError"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ resendError }}
</div>
</div>
</v-form>
</div>
</div>
</v-form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { onMounted, ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
// Verification state
const isLoading = ref(true);
const verificationSuccess = ref(false);
const errorMessage = ref('');
// Verification state
const isLoading = ref(true);
const verificationSuccess = ref(false);
const errorMessage = ref('');
// Resend verification state
const resendEmail = ref('');
const resendEmailError = ref('');
const resendLoading = ref(false);
const resendSuccess = ref(false);
const resendError = ref('');
// Resend verification state
const resendEmail = ref('');
const resendEmailError = ref('');
const resendLoading = ref(false);
const resendSuccess = ref(false);
const resendError = ref('');
onMounted(async () => {
const userId = route.query.userId;
const token = route.query.token;
onMounted(async () => {
const userId = route.query.userId;
const token = route.query.token;
// Populate resend email field if it was in the URL
if (route.query.email) {
resendEmail.value = route.query.email;
}
// Populate resend email field if it was in the URL
if (route.query.email) {
resendEmail.value = route.query.email;
}
// Check if we have the required parameters
if (!userId || !token) {
isLoading.value = false;
errorMessage.value = t('error.missingParams');
return;
}
// Check if we have the required parameters
if (!userId || !token) {
isLoading.value = false;
errorMessage.value = t('error.missingParams');
return;
}
try {
// Call the verification endpoint
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`);
verificationSuccess.value = true;
} catch (error) {
console.error('Email verification failed:', error);
errorMessage.value = error.response?.data?.message || t('error.defaultMessage');
} finally {
isLoading.value = false;
}
});
async function handleResendVerification() {
// Reset states
resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim()
try {
// Call the verification endpoint
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`);
verificationSuccess.value = true;
} catch (error) {
console.error('Email verification failed:', error);
errorMessage.value = error.response?.data?.message || t('error.defaultMessage');
} finally {
isLoading.value = false;
}
});
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() {
router.push('/login');
}
async function handleResendVerification() {
// Reset states
resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim(),
});
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() {
router.push('/login');
}
</script>
<i18n>
{
"en": {
"verifying": "Verifying your email...",
"success": {
"title": "Email Verified Successfully!",
"message": "Your email has been verified. You can now log in to your account.",
"goToLogin": "Go to Login"
"en": {
"verifying": "Verifying your email...",
"success": {
"title": "Email Verified Successfully!",
"message": "Your email has been verified. You can now log in to your account.",
"goToLogin": "Go to Login"
},
"error": {
"title": "Verification Failed",
"defaultMessage": "We couldn't verify your email. The link may be invalid or expired.",
"missingParams": "Missing required verification parameters.",
"goToLogin": "Go to Login"
},
"resend": {
"title": "Resend Verification Email",
"emailLabel": "Email",
"button": "Resend Verification Email",
"success": "Verification email sent successfully. Please check your inbox.",
"error": "Failed to send verification email. Please try again.",
"invalidEmail": "Please enter a valid email address."
}
},
"error": {
"title": "Verification Failed",
"defaultMessage": "We couldn't verify your email. The link may be invalid or expired.",
"missingParams": "Missing required verification parameters.",
"goToLogin": "Go to Login"
},
"resend": {
"title": "Resend Verification Email",
"emailLabel": "Email",
"button": "Resend Verification Email",
"success": "Verification email sent successfully. Please check your inbox.",
"error": "Failed to send verification email. Please try again.",
"invalidEmail": "Please enter a valid email address."
"fr": {
"verifying": "Vérification de votre email...",
"success": {
"title": "Email vérifié avec succès !",
"message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.",
"goToLogin": "Aller à la connexion"
},
"error": {
"title": "Échec de la vérification",
"defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.",
"missingParams": "Paramètres de vérification requis manquants.",
"goToLogin": "Aller à la connexion"
},
"resend": {
"title": "Renvoyer l'email de vérification",
"emailLabel": "Email",
"button": "Renvoyer l'email de vérification",
"success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
}
},
"fr": {
"verifying": "Vérification de votre email...",
"success": {
"title": "Email vérifié avec succès !",
"message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.",
"goToLogin": "Aller à la connexion"
},
"error": {
"title": "Échec de la vérification",
"defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.",
"missingParams": "Paramètres de vérification requis manquants.",
"goToLogin": "Aller à la connexion"
},
"resend": {
"title": "Renvoyer l'email de vérification",
"emailLabel": "Email",
"button": "Renvoyer l'email de vérification",
"success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
},
"es": {
"verifying": "Verificando tu correo electrónico...",
"success": {
"title": "¡Correo electrónico verificado con éxito!",
"message": "Tu correo electrónico ha sido verificado. Ahora puedes iniciar sesión en tu cuenta.",
"goToLogin": "Ir al inicio de sesión"
},
"error": {
"title": "Falló la verificación",
"defaultMessage": "No pudimos verificar tu correo electrónico. El enlace puede ser inválido o estar caducado.",
"missingParams": "Faltan parámetros de verificación requeridos.",
"goToLogin": "Ir al inicio de sesión"
},
"resend": {
"title": "Reenviar correo de verificación",
"emailLabel": "Correo electrónico",
"button": "Reenviar correo de verificación",
"success": "Correo de verificación enviado con éxito. Por favor revisa tu bandeja de entrada.",
"error": "Error al enviar el correo de verificación. Por favor, inténtelo de nuevo.",
"invalidEmail": "Por favor, introduce una dirección de correo electrónico válida."
}
}
}
</i18n>

View File

@@ -681,36 +681,6 @@
"descriptionRequired": "La description est obligatoire"
}
}
},
"es": {
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"creator": {
"sections": {
"about": {
"title": "Acerca de",
"description": "Descripción",
"contactInfo": "Información de contacto",
"characters": "caracteres",
"formattingHint": "Consejo: ¡Usa saltos de línea y emojis para hacer tu descripción más atractiva!"
},
"photos": {
"title": "Fotos",
"image": "Imagen"
}
},
"fields": {
"videoUrl": "URL del video",
"phoneNumber": "Número de teléfono",
"email": "Correo electrónico"
},
"validation": {
"invalidYoutubeUrl": "Por favor, introduce una URL de YouTube o un ID de video válido",
"descriptionTooLong": "La descripción no puede exceder los 2000 caracteres",
"descriptionRequired": "La descripción es obligatoria"
}
}
}
}
</i18n>

View File

@@ -1,64 +1,81 @@
<template>
<div class="relative">
<!-- Banner Container with mouse events -->
<div class="relative overflow-y-auto rounded-b-2xl" @mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false" @click="isCurrentCreator && openBannerEditor()">
<img class="banner aspect-[4/1] w-full object-cover"
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'" :alt="t('alt')">
<!-- Tint Effect -->
<div v-if="showTint" class="absolute inset-0 cursor-pointer bg-black/25">
<!-- Top-right Icon -->
<div class="relative">
<!-- Banner Container with mouse events -->
<div
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg">
<v-icon large :icon="mdiPencil" />
class="relative overflow-y-auto rounded-b-2xl"
@click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<img
:alt="t('alt')"
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'"
class="banner aspect-[4/1] w-full object-cover"
/>
<!-- Tint Effect -->
<div
v-if="showTint"
class="absolute inset-0 cursor-pointer bg-black/25"
>
<!-- Top-right Icon -->
<div
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div>
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<BannerEditor :creator="brandingStore.value" @closeRequested="() => isDialogOpen = false" />
</v-dialog>
<v-dialog
v-model="isDialogOpen"
max-width="800px"
>
<BannerEditor
:creator="brandingStore.value"
@closeRequested="() => (isDialogOpen = false)"
/>
</v-dialog>
</template>
<script setup>
import BannerEditor from "@/views/creators/BannerEditor.vue";
import { computed, ref } from "vue";
import { useBrandingStore } from "@/stores/brandingStore.js";
import { useAuthStore } from "@/stores/authStore.js";
import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js';
import BannerEditor from '@/views/creators/BannerEditor.vue';
import { computed, ref } from 'vue';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>
<style scoped></style>
<i18n>
{
"en": {
"alt": "Creator banner"
},
"fr": {
"alt": "Bannière du créateur"
},
"es": {
"alt": "Banner del creador"
}
"en": {
"alt": "Creator banner"
},
"fr": {
"alt": "Bannière du créateur"
}
}
</i18n>

View File

@@ -1,330 +1,367 @@
<template>
<div class="album-editor">
<div class="album-editor">
<h2 class="mb-4 text-xl font-semibold">
{{ t('title') }}
</h2>
<h2 class="mb-4 text-xl font-semibold">
{{ t('title') }}
</h2>
<!-- Drop zone with photos -->
<div class="drop-zone" @dragover.prevent @drop.prevent="handleDrop" @click="triggerFileInput">
<!-- Upload prompt -->
<div class="drop-zone-content">
<v-icon size="large" :icon="mdiPlus" />
<span class="mt-2 text-sm">{{ t('dropzoneText') }}</span>
</div>
<!-- Hidden file input -->
<input type="file" ref="fileInput" @change="handleFileUpload" accept="image/*" multiple class="hidden" />
<!-- Photos grid -->
<draggable v-model="localImages" class="photos-grid" item-key="id" @end="handleReorder" :filter="'.action-btn'"
:prevent-on-filter="false">
<template #item="{ element, index }">
<div class="photo-wrapper">
<div class="index-bubble">{{ index + 1 }}</div>
<img :src="element.image.originalUrl" :alt="'Image ' + (index + 1)" />
<!-- Processing spinner overlay -->
<div v-if="element.isProcessing" class="loading-overlay">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
<!-- Drop zone with photos -->
<div
class="drop-zone"
@click="triggerFileInput"
@dragover.prevent
@drop.prevent="handleDrop"
>
<!-- Upload prompt -->
<div class="drop-zone-content">
<v-icon
:icon="mdiPlus"
size="large"
/>
<span class="mt-2 text-sm">{{ t('dropzoneText') }}</span>
</div>
<!-- Upload spinner overlay -->
<div v-if="element.isUploading" class="loading-overlay uploading">
<v-progress-circular indeterminate color="secondary"></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
</div>
<!-- Left arrow -->
<button @click.stop="moveImage(index, 'up')" @touchstart.stop="moveImage(index, 'up')"
class="action-btn left-btn" :disabled="index === 0" :title="t('moveLeft')">
<v-icon :icon="mdiArrowLeft" />
</button>
<!-- Right arrow -->
<button @click.stop="moveImage(index, 'down')" @touchstart.stop="moveImage(index, 'down')"
class="action-btn right-btn" :disabled="index === localImages.length - 1" :title="t('moveRight')">
<v-icon :icon="mdiArrowRight" />
</button>
<!-- Delete button -->
<button @click.stop="deleteImage(index)" touchstart.stop="deleteImage(index)" class="action-btn delete-btn"
:title="t('delete')">
<v-icon :icon="mdiDelete" />
</button>
</div>
</template>
</draggable>
<!-- Hidden file input -->
<input
ref="fileInput"
accept="image/*"
class="hidden"
multiple
type="file"
@change="handleFileUpload"
/>
<!-- Photos grid -->
<draggable
v-model="localImages"
:filter="'.action-btn'"
:prevent-on-filter="false"
class="photos-grid"
item-key="id"
@end="handleReorder"
>
<template #item="{ element, index }">
<div class="photo-wrapper">
<div class="index-bubble">{{ index + 1 }}</div>
<img
:alt="'Image ' + (index + 1)"
:src="element.image.originalUrl"
/>
<!-- Processing spinner overlay -->
<div
v-if="element.isProcessing"
class="loading-overlay"
>
<v-progress-circular
color="primary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
</div>
<!-- Upload spinner overlay -->
<div
v-if="element.isUploading"
class="loading-overlay uploading"
>
<v-progress-circular
color="secondary"
indeterminate
></v-progress-circular>
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
</div>
<!-- Left arrow -->
<button
:disabled="index === 0"
:title="t('moveLeft')"
class="action-btn left-btn"
@click.stop="moveImage(index, 'up')"
@touchstart.stop="moveImage(index, 'up')"
>
<v-icon :icon="mdiArrowLeft" />
</button>
<!-- Right arrow -->
<button
:disabled="index === localImages.length - 1"
:title="t('moveRight')"
class="action-btn right-btn"
@click.stop="moveImage(index, 'down')"
@touchstart.stop="moveImage(index, 'down')"
>
<v-icon :icon="mdiArrowRight" />
</button>
<!-- Delete button -->
<button
:title="t('delete')"
class="action-btn delete-btn"
touchstart.stop="deleteImage(index)"
@click.stop="deleteImage(index)"
>
<v-icon :icon="mdiDelete" />
</button>
</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useI18n } from 'vue-i18n';
import { v7 } from 'uuid';
import draggable from 'vuedraggable';
import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { v7 } from 'uuid';
import draggable from 'vuedraggable';
import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js';
const props = defineProps({
images: {
type: Array,
required: true
}
});
const props = defineProps({
images: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:images']);
const emit = defineEmits(['update:images']);
const { t } = useI18n();
const fileInput = ref(null);
const localImages = ref([]);
const { t } = useI18n();
const fileInput = ref(null);
const localImages = ref([]);
onMounted(() => {
// Initialize local images with IDs and states
localImages.value = props.images;
});
onMounted(() => {
// Initialize local images with IDs and states
localImages.value = props.images;
});
function handleFiles(files) {
console.log('handleFiles:', files)
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
const reader = new FileReader();
// Create a temporary image object with processing state
const tempImage = {
image: {
id: v7(),
originalUrl: '',
},
file: file,
isProcessing: true,
isUploading: false,
};
localImages.value.push(tempImage);
function handleFiles(files) {
console.log('handleFiles:', files);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
const reader = new FileReader();
// Create a temporary image object with processing state
const tempImage = {
image: {
id: v7(),
originalUrl: '',
},
file: file,
isProcessing: true,
isUploading: false,
};
localImages.value.push(tempImage);
console.log('Processing image:', tempImage);
console.log('Processing image:', tempImage);
reader.onload = (e) => {
console.log('Image loaded:', e);
const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id);
if (index !== -1) {
localImages.value[index].image.originalUrl = e.target.result;
localImages.value[index].isProcessing = false;
emit('update:images', localImages.value);
}
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error processing image:', error);
}
reader.onload = e => {
console.log('Image loaded:', e);
const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id);
if (index !== -1) {
localImages.value[index].image.originalUrl = e.target.result;
localImages.value[index].isProcessing = false;
emit('update:images', localImages.value);
}
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error processing image:', error);
}
}
}
}
}
}
function handleDrop(event) {
console.log('Drop triggered');
const files = Array.from(event.dataTransfer.files);
handleFiles(files);
}
function triggerFileInput() {
console.log('Input triggered');
fileInput.value.click();
}
function handleDrop(event) {
console.log('Drop triggered');
const files = Array.from(event.dataTransfer.files);
handleFiles(files);
}
function handleFileUpload(event) {
console.log('File input triggered');
const files = Array.from(event.target.files);
handleFiles(files);
event.target.value = '';
}
function triggerFileInput() {
console.log('Input triggered');
fileInput.value.click();
}
function handleReorder() {
emit('update:images', localImages.value);
}
function handleFileUpload(event) {
console.log('File input triggered');
const files = Array.from(event.target.files);
handleFiles(files);
event.target.value = '';
}
function handleReorder() {
emit('update:images', localImages.value);
}
function moveImage(index, direction) {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex >= 0 && newIndex < localImages.value.length) {
const temp = localImages.value[index];
localImages.value[index] = localImages.value[newIndex];
localImages.value[newIndex] = temp;
emit('update:images', localImages.value);
}
}
function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value);
}
function moveImage(index, direction) {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex >= 0 && newIndex < localImages.value.length) {
const temp = localImages.value[index];
localImages.value[index] = localImages.value[newIndex];
localImages.value[newIndex] = temp;
emit('update:images', localImages.value);
}
}
function deleteImage(index) {
localImages.value.splice(index, 1);
emit('update:images', localImages.value);
}
</script>
<style scoped>
.album-editor {
@apply w-full;
}
.album-editor {
@apply w-full;
}
.drop-zone {
@apply w-full;
@apply min-h-[200px];
@apply border-2;
@apply border-dashed;
@apply border-gray-300 hover:border-gray-500;
@apply rounded-lg;
@apply p-4;
@apply relative;
@apply transition-colors;
@apply duration-200;
@apply overflow-visible;
@apply bg-hSurface;
}
.drop-zone {
@apply w-full;
@apply min-h-[200px];
@apply border-2;
@apply border-dashed;
@apply border-gray-300 hover:border-gray-500;
@apply rounded-lg;
@apply p-4;
@apply relative;
@apply transition-colors;
@apply duration-200;
@apply overflow-visible;
@apply bg-hSurface;
}
.drop-zone-content {
@apply flex;
@apply flex-col;
@apply items-center;
@apply text-gray-500;
@apply mb-8;
@apply relative;
@apply z-10;
}
.drop-zone-content {
@apply flex;
@apply flex-col;
@apply items-center;
@apply text-gray-500;
@apply mb-8;
@apply relative;
@apply z-10;
}
.photos-grid {
@apply grid;
@apply grid-cols-2;
@apply sm:grid-cols-3;
@apply md:grid-cols-4;
@apply lg:grid-cols-5;
@apply gap-4;
@apply w-full;
@apply pb-1;
}
.photos-grid {
@apply grid;
@apply grid-cols-2;
@apply sm:grid-cols-3;
@apply md:grid-cols-4;
@apply lg:grid-cols-5;
@apply gap-4;
@apply w-full;
@apply pb-1;
}
.photo-wrapper {
@apply relative;
@apply aspect-square;
@apply rounded-lg;
@apply overflow-hidden;
@apply bg-gray-100;
@apply cursor-pointer;
}
.photo-wrapper {
@apply relative;
@apply aspect-square;
@apply rounded-lg;
@apply overflow-hidden;
@apply bg-gray-100;
@apply cursor-pointer;
}
.photo-wrapper img {
@apply w-full;
@apply h-full;
@apply object-cover;
@apply pointer-events-none;
}
.photo-wrapper img {
@apply w-full;
@apply h-full;
@apply object-cover;
@apply pointer-events-none;
}
.action-btn {
@apply absolute;
@apply bg-black;
@apply bg-opacity-50;
@apply text-white;
@apply rounded-full;
@apply p-1;
@apply flex;
@apply items-center;
@apply justify-center;
@apply transition-all;
@apply duration-200;
@apply opacity-0;
@apply z-10;
}
.action-btn {
@apply absolute;
@apply bg-black;
@apply bg-opacity-50;
@apply text-white;
@apply rounded-full;
@apply p-1;
@apply flex;
@apply items-center;
@apply justify-center;
@apply transition-all;
@apply duration-200;
@apply opacity-0;
@apply z-10;
}
/* Show buttons on hover for desktop */
.action-btn {
@apply opacity-100;
}
/* Show buttons on hover for desktop */
.action-btn {
@apply opacity-100;
}
.action-btn:disabled {
@apply opacity-30;
@apply cursor-not-allowed;
@apply bg-gray-500;
@apply scale-90;
}
.action-btn:disabled {
@apply opacity-30;
@apply cursor-not-allowed;
@apply bg-gray-500;
@apply scale-90;
}
.left-btn {
@apply top-1/2;
@apply -translate-y-1/2;
@apply left-2;
}
.left-btn {
@apply top-1/2;
@apply -translate-y-1/2;
@apply left-2;
}
.right-btn {
@apply top-1/2;
@apply -translate-y-1/2;
@apply right-2;
}
.right-btn {
@apply top-1/2;
@apply -translate-y-1/2;
@apply right-2;
}
.delete-btn {
@apply top-2;
@apply right-2;
@apply bg-red-500;
@apply bg-opacity-50;
}
.delete-btn {
@apply top-2;
@apply right-2;
@apply bg-red-500;
@apply bg-opacity-50;
}
.index-bubble {
@apply absolute;
@apply top-2;
@apply left-2;
@apply bg-black;
@apply bg-opacity-50;
@apply text-white;
@apply text-xs;
@apply font-medium;
@apply rounded-full;
@apply w-6;
@apply h-6;
@apply flex;
@apply items-center;
@apply justify-center;
@apply z-10;
}
.index-bubble {
@apply absolute;
@apply top-2;
@apply left-2;
@apply bg-black;
@apply bg-opacity-50;
@apply text-white;
@apply text-xs;
@apply font-medium;
@apply rounded-full;
@apply w-6;
@apply h-6;
@apply flex;
@apply items-center;
@apply justify-center;
@apply z-10;
}
.loading-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply flex-col;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-50;
@apply z-20;
}
.loading-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply flex-col;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-50;
@apply z-20;
}
.loading-overlay.uploading {
@apply bg-opacity-75;
}
.loading-overlay.uploading {
@apply bg-opacity-75;
}
</style>
<i18n>
{
"en": {
"title": "Album",
"dropzoneText": "Drop a photo here to add it to your album",
"processing": "Processing...",
"uploading": "Uploading...",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"delete": "Delete"
},
"fr": {
"title": "Album",
"dropzoneText": "Déposez une photo ici pour l'ajouter à l'album",
"processing": "Traitement en cours...",
"uploading": "Téléchargement...",
"moveLeft": "Déplacer à gauche",
"moveRight": "Déplacer à droite",
"delete": "Supprimer"
},
"es": {
"title": "Album",
"dropzoneText": "Suelta una foto aquí para añadirla al álbum",
"processing": "Procesando...",
"uploading": "Subiendo...",
"moveLeft": "Mover a la izquierda",
"moveRight": "Mover a la derecha",
"delete": "Eliminar"
}
"en": {
"title": "Album",
"dropzoneText": "Drop a photo here to add it to your album",
"processing": "Processing...",
"uploading": "Uploading...",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"delete": "Delete"
},
"fr": {
"title": "Album",
"dropzoneText": "Déposez une photo ici pour l'ajouter à l'album",
"processing": "Traitement en cours...",
"uploading": "Téléchargement...",
"moveLeft": "Déplacer à gauche",
"moveRight": "Déplacer à droite",
"delete": "Supprimer"
}
}
</i18n>

View File

@@ -1,138 +1,134 @@
<template>
<div v-if="hasImages" class="album-view">
<!-- Album Display -->
<div class="image-grid">
<div v-for="(url, index) in displayedImages"
:key="index"
class="image-wrapper"
@click="$emit('photo-click', index)">
<img :src="url"
:alt="t('creator.sections.album.image')"
class="image"/>
</div>
<div
v-if="hasImages"
class="album-view"
>
<!-- Album Display -->
<div class="image-grid">
<div
v-for="(url, index) in displayedImages"
:key="index"
class="image-wrapper"
@click="$emit('photo-click', index)"
>
<img
:alt="t('creator.sections.album.image')"
:src="url"
class="image"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
// Add 'photo-click' to emits
const emit = defineEmits(['photo-click']);
// Add 'photo-click' to emits
const emit = defineEmits(['photo-click']);
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
images: {
type: Array,
required: true,
default: () => []
}
});
const props = defineProps({
images: {
type: Array,
required: true,
default: () => [],
},
});
const { t } = useI18n();
const { t } = useI18n();
// Add a reactive window width
const windowWidth = ref(window.innerWidth);
// Add a reactive window width
const windowWidth = ref(window.innerWidth);
// Update window width on resize
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
// Update window width on resize
const handleResize = () => {
windowWidth.value = window.innerWidth;
};
// Add and remove event listener
onMounted(() => {
window.addEventListener('resize', handleResize);
});
// Add and remove event listener
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
const hasImages = computed(() => {
return props.images.some(url => url);
});
const hasImages = computed(() => {
return props.images.some(url => url);
});
const nonEmptyImages = computed(() => {
return props.images.filter(url => url);
});
const nonEmptyImages = computed(() => {
return props.images.filter(url => url);
});
// Show different number of images based on reactive window width
const displayedImages = computed(() => {
const images = nonEmptyImages.value;
if (windowWidth.value >= 1024) {
return images.slice(0, 5); // 5 images on large screens
} else if (windowWidth.value >= 768) {
return images.slice(0, 4); // 4 images on medium screens
}
return images.slice(0, 3); // 3 images on smaller screens
});
// Show different number of images based on reactive window width
const displayedImages = computed(() => {
const images = nonEmptyImages.value;
if (windowWidth.value >= 1024) {
return images.slice(0, 5); // 5 images on large screens
} else if (windowWidth.value >= 768) {
return images.slice(0, 4); // 4 images on medium screens
}
return images.slice(0, 3); // 3 images on smaller screens
});
// Add computed property for grid columns based on number of images
const gridColumns = computed(() => {
const count = displayedImages.value.length;
if (count === 1) return '1fr';
if (count === 2) return 'repeat(2, 1fr)';
if (count === 3) return 'repeat(3, 1fr)';
if (count === 4) return 'repeat(4, 1fr)';
return 'repeat(5, 1fr)';
});
// Add computed property for grid columns based on number of images
const gridColumns = computed(() => {
const count = displayedImages.value.length;
if (count === 1) return '1fr';
if (count === 2) return 'repeat(2, 1fr)';
if (count === 3) return 'repeat(3, 1fr)';
if (count === 4) return 'repeat(4, 1fr)';
return 'repeat(5, 1fr)';
});
</script>
<style scoped>
.album-view {
@apply w-full;
}
.album-view {
@apply w-full;
}
.image-grid {
@apply w-full grid;
grid-template-columns: v-bind(gridColumns);
}
.image-grid {
@apply w-full grid;
grid-template-columns: v-bind(gridColumns);
}
.image-wrapper {
@apply relative w-full aspect-square;
}
.image-wrapper {
@apply relative w-full aspect-square;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Responsive adjustments */
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Responsive adjustments */
</style>
<i18n>
{
"en": {
"creator": {
"sections": {
"album": {
"title": "Photo Album",
"image": "Album image"
"en": {
"creator": {
"sections": {
"album": {
"title": "Photo Album",
"image": "Album image"
}
}
}
}
}
},
"fr": {
"creator": {
"sections": {
"album": {
"title": "Album photo",
"image": "Image de l'album"
},
"fr": {
"creator": {
"sections": {
"album": {
"title": "Album photo",
"image": "Image de l'album"
}
}
}
}
}
},
"es": {
"creator": {
"sections": {
"album": {
"title": "Álbum de fotos",
"image": "Imagen del álbum"
}
}
}
}
}
</i18n>

View File

@@ -1,175 +1,207 @@
<template>
<v-dialog v-model="dialog" fullscreen :scrim="true" transition="dialog-bottom-transition"
@click:outside="closeViewer">
<div class="album-viewer" @click.self="closeViewer">
<!-- Main image container -->
<div class="image-container">
<img :src="currentImage" :alt="t('viewer.imageAlt', { index: currentIndex + 1 })" class="main-image" />
<v-dialog
v-model="dialog"
:scrim="true"
fullscreen
transition="dialog-bottom-transition"
@click:outside="closeViewer"
>
<div
class="album-viewer"
@click.self="closeViewer"
>
<!-- Main image container -->
<div class="image-container">
<img
:alt="t('viewer.imageAlt', { index: currentIndex + 1 })"
:src="currentImage"
class="main-image"
/>
<!-- Navigation buttons -->
<button class="nav-btn left-btn" @click.stop="previousImage" :disabled="currentIndex === 0"
:title="t('viewer.previous')">
<v-icon size="large" color="white" :icon="mdiChevronLeft" />
</button>
<!-- Navigation buttons -->
<button
:disabled="currentIndex === 0"
:title="t('viewer.previous')"
class="nav-btn left-btn"
@click.stop="previousImage"
>
<v-icon
:icon="mdiChevronLeft"
color="white"
size="large"
/>
</button>
<button class="nav-btn right-btn" @click.stop="nextImage" :disabled="currentIndex === images.length - 1"
:title="t('viewer.next')">
<v-icon size="large" color="white" :icon="mdiChevronRight" />
</button>
<button
:disabled="currentIndex === images.length - 1"
:title="t('viewer.next')"
class="nav-btn right-btn"
@click.stop="nextImage"
>
<v-icon
:icon="mdiChevronRight"
color="white"
size="large"
/>
</button>
<!-- Close button -->
<button class="close-btn" @click.stop="closeViewer" :title="t('viewer.close')">
<v-icon size="large" color="white" :icon="mdiClose" />
</button>
<!-- Close button -->
<button
:title="t('viewer.close')"
class="close-btn"
@click.stop="closeViewer"
>
<v-icon
:icon="mdiClose"
color="white"
size="large"
/>
</button>
<!-- Image counter -->
<div class="image-counter">
{{ currentIndex + 1 }} / {{ images.length }}
<!-- Image counter -->
<div class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</div>
</div>
</div>
</div>
</div>
</v-dialog>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js';
const { t } = useI18n();
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
images: {
type: Array,
required: true
},
startIndex: {
type: Number,
default: 0
}
});
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
images: {
type: Array,
required: true,
},
startIndex: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue']);
const dialog = ref(false);
const currentIndex = ref(0);
const dialog = ref(false);
const currentIndex = ref(0);
const currentImage = computed(() => props.images[currentIndex.value]);
const currentImage = computed(() => props.images[currentIndex.value]);
watch(() => props.modelValue, (newVal) => {
dialog.value = newVal;
if (newVal) {
currentIndex.value = props.startIndex;
}
});
watch(
() => props.modelValue,
newVal => {
dialog.value = newVal;
if (newVal) {
currentIndex.value = props.startIndex;
}
}
);
watch(() => dialog.value, (newVal) => {
emit('update:modelValue', newVal);
});
watch(
() => dialog.value,
newVal => {
emit('update:modelValue', newVal);
}
);
function nextImage() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++;
}
}
function nextImage() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++;
}
}
function previousImage() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
function previousImage() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
function closeViewer() {
dialog.value = false;
}
function closeViewer() {
dialog.value = false;
}
</script>
<style scoped>
.album-viewer {
@apply fixed inset-0;
@apply flex items-center justify-center;
@apply bg-black bg-opacity-90;
@apply z-50;
}
.album-viewer {
@apply fixed inset-0;
@apply flex items-center justify-center;
@apply bg-black bg-opacity-90;
@apply z-50;
}
.image-container {
@apply relative;
@apply max-w-[90vw];
@apply max-h-[90vh];
}
.image-container {
@apply relative;
@apply max-w-[90vw];
@apply max-h-[90vh];
}
.main-image {
@apply max-w-full;
@apply max-h-[90vh];
@apply object-contain;
}
.main-image {
@apply max-w-full;
@apply max-h-[90vh];
@apply object-contain;
}
.nav-btn {
@apply absolute top-1/2 -translate-y-1/2;
@apply p-4;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
@apply disabled:opacity-30 disabled:cursor-not-allowed;
}
.nav-btn {
@apply absolute top-1/2 -translate-y-1/2;
@apply p-4;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
@apply disabled:opacity-30 disabled:cursor-not-allowed;
}
.left-btn {
@apply left-4;
}
.left-btn {
@apply left-4;
}
.right-btn {
@apply right-4;
}
.right-btn {
@apply right-4;
}
.close-btn {
@apply absolute top-4 right-4;
@apply p-2;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
}
.close-btn {
@apply absolute top-4 right-4;
@apply p-2;
@apply rounded-full;
@apply bg-black bg-opacity-50;
@apply transition-all duration-200;
@apply hover:bg-opacity-75;
}
.image-counter {
@apply absolute bottom-4 left-1/2 -translate-x-1/2;
@apply px-4 py-2;
@apply bg-black bg-opacity-50;
@apply text-white;
@apply rounded-full;
@apply text-sm;
}
.image-counter {
@apply absolute bottom-4 left-1/2 -translate-x-1/2;
@apply px-4 py-2;
@apply bg-black bg-opacity-50;
@apply text-white;
@apply rounded-full;
@apply text-sm;
}
</style>
<i18n>
{
"en": {
"viewer": {
"previous": "Previous image",
"next": "Next image",
"close": "Close viewer",
"imageAlt": "Image {index}"
"en": {
"viewer": {
"previous": "Previous image",
"next": "Next image",
"close": "Close viewer",
"imageAlt": "Image {index}"
}
},
"fr": {
"viewer": {
"previous": "Image précédente",
"next": "Image suivante",
"close": "Fermer",
"imageAlt": "Image {index}"
}
}
},
"fr": {
"viewer": {
"previous": "Image précédente",
"next": "Image suivante",
"close": "Fermer",
"imageAlt": "Image {index}"
}
},
"es": {
"viewer": {
"previous": "Imagen anterior",
"next": "Imagen siguiente",
"close": "Cerrar",
"imageAlt": "Imagen {index}"
}
}
}
</i18n>

View File

@@ -1,172 +1,170 @@
<script setup>
import {useBrandingStore} from '@/stores/brandingStore.js';
import DonationButton from '@/views/creators/DonationButton.vue';
import CreatorLogo from "@/views/creators/CreatorLogo.vue";
import NameTitle from "@/views/creators/NameTitle.vue";
import Linkedin from "@/views/svg/Linkedin.vue";
import X from "@/views/svg/X.vue";
import Facebook from "@/views/svg/Facebook.vue";
import Instagram from "@/views/svg/Instagram.vue";
import Tiktok from "@/views/svg/Tiktok.vue";
import Reddit from "@/views/svg/Reddit.vue";
import Youtube from "@/views/svg/Youtube.vue";
import Web from "@/views/svg/Web.vue";
import {useI18n} from 'vue-i18n'
import { useBrandingStore } from '@/stores/brandingStore.js';
import DonationButton from '@/views/creators/DonationButton.vue';
import CreatorLogo from '@/views/creators/CreatorLogo.vue';
import NameTitle from '@/views/creators/NameTitle.vue';
import Linkedin from '@/views/svg/Linkedin.vue';
import X from '@/views/svg/X.vue';
import Facebook from '@/views/svg/Facebook.vue';
import Instagram from '@/views/svg/Instagram.vue';
import Tiktok from '@/views/svg/Tiktok.vue';
import Reddit from '@/views/svg/Reddit.vue';
import Youtube from '@/views/svg/Youtube.vue';
import Web from '@/views/svg/Web.vue';
import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
const {t} = useI18n();
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
const { t } = useI18n();
</script>
<template>
<div class="flex flex-column w-full">
<!-- Container principal avec le profil -->
<div class="relative w-full">
<div class="bg-hPrimary text-hOnPrimary relative">
<div class="flex flex-column w-full">
<!-- Container principal avec le profil -->
<div class="relative w-full">
<div class="bg-hPrimary text-hOnPrimary relative">
<!-- Portrait that overlaps both sections -->
<div class="absolute left-4 -bottom-2 z-10">
<creator-logo />
</div>
<!-- Portrait that overlaps both sections -->
<div class="absolute left-4 -bottom-2 z-10">
<creator-logo/>
<!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="social-info">
<div class="w-36"></div>
<div class="flex-grow flex flex-row">
<div class="flex-grow">
<name-title></name-title>
</div>
<div class="hidden sm:flex pr-6">
<DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Desktop version (visible only on écrans moyens et grands) -->
<div class="social-info">
<div class="w-36">
</div>
<div class="flex-grow flex flex-row">
<div class="flex-grow">
<name-title></name-title>
<!-- Section pour les icônes de réseaux sociaux -->
<div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
<div class="flex flex-row gap-10">
<a
v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl"
:title="t('facebook')"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a
v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
:title="t('instagram')"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
:title="t('linkedin')"
target="_blank"
>
<linkedin class="social-icon"></linkedin>
</a>
<a
v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
:title="t('reddit')"
target="_blank"
>
<reddit class="social-icon"></reddit>
</a>
<a
v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
:title="t('tiktok')"
target="_blank"
>
<tiktok class="social-icon"></tiktok>
</a>
<a
v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
:title="t('x')"
target="_blank"
>
<x class="social-icon"></x>
</a>
<a
v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
:title="t('youtube')"
target="_blank"
>
<youtube class="social-icon"></youtube>
</a>
<a
v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
:title="t('website')"
target="_blank"
>
<web class="social-icon"></web>
</a>
</div>
<div class="hidden sm:flex pr-6">
<DonationButton
v-if="brandingStore.value?.acceptDonation"
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Section pour les icônes de réseaux sociaux -->
<div
class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative"
>
<div class="flex flex-row gap-10">
<a v-if="brandingStore.value?.socials?.facebookUrl"
:href="brandingStore.value?.socials?.facebookUrl"
target="_blank"
:title="t('facebook')">
<facebook class="social-icon"></facebook>
</a>
<a v-if="brandingStore.value?.socials?.instagramUrl"
:href="brandingStore.value?.socials?.instagramUrl"
target="_blank"
:title="t('instagram')">
<instagram class="social-icon"></instagram>
</a>
<a v-if="brandingStore.value?.socials?.linkedInUrl"
:href="brandingStore.value?.socials?.linkedInUrl"
target="_blank"
:title="t('linkedin')">
<linkedin class="social-icon"></linkedin>
</a>
<a v-if="brandingStore.value?.socials?.redditUrl"
:href="brandingStore.value?.socials?.redditUrl"
target="_blank"
:title="t('reddit')">
<reddit class="social-icon"></reddit>
</a>
<a v-if="brandingStore.value?.socials?.tikTokUrl"
:href="brandingStore.value?.socials?.tikTokUrl"
target="_blank"
:title="t('tiktok')">
<tiktok class="social-icon"></tiktok>
</a>
<a v-if="brandingStore.value?.socials?.xUrl"
:href="brandingStore.value?.socials?.xUrl"
target="_blank"
:title="t('x')">
<x class="social-icon"></x>
</a>
<a v-if="brandingStore.value?.socials?.youtubeUrl"
:href="brandingStore.value?.socials?.youtubeUrl"
target="_blank"
:title="t('youtube')">
<youtube class="social-icon"></youtube>
</a>
<a v-if="brandingStore.value?.socials?.websiteUrl"
:href="brandingStore.value?.socials?.websiteUrl"
target="_blank"
:title="t('website')">
<web class="social-icon"></web>
</a>
</div>
</div>
</div>
</template>
<style scoped>
.social-icon {
@apply w-5 h-5;
@apply text-base;
@apply transform transition-transform duration-200;
@apply hover:scale-125 hover:text-fuchsia-900;
}
.social-icon {
@apply w-5 h-5;
@apply text-base;
@apply transform transition-transform duration-200;
@apply hover:scale-125 hover:text-fuchsia-900;
}
.social-info {
@apply flex flex-row;
@apply py-4 w-full;
@apply justify-center ;
@apply max-h-52;
}
.social-info {
@apply flex flex-row;
@apply py-4 w-full;
@apply justify-center;
@apply max-h-52;
}
</style>
<i18n>
{
"en": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Website"
},
"fr": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Site web"
},
"es": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Sitio web"
}
"en": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Website"
},
"fr": {
"facebook": "Facebook",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"reddit": "Reddit",
"tiktok": "TikTok",
"x": "X (Twitter)",
"youtube": "YouTube",
"website": "Site web"
}
}
</i18n>

View File

@@ -1,375 +1,377 @@
<template>
<div class="card">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<p class="card-text">
{{ t('description') }}
</p>
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="4"
:stencil-props="{
aspectRatio: 4,
class: 'banner-stencil'
}"
/>
</div>
<div v-else class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
<div class="card">
<div class="card-title">
{{ t('title') }}
</div>
</div>
</div>
<div class="card-actions">
<button class="secondary"
@click="cancel"
:disabled="isUploading">
{{ t('cancel') }}
</button>
<button class="primary"
@click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading">
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
<div class="card-content">
<p class="card-text">
{{ t('description') }}
</p>
<div class="file-input-container">
<input
ref="fileInput"
accept="image/*"
class="hidden"
type="file"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:aspect-ratio="4"
:src="fileUrl"
:stencil-props="{
aspectRatio: 4,
class: 'banner-stencil',
}"
/>
</div>
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop"
>
<img
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
</div>
</div>
<div class="card-actions">
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue'
import {useClient} from '@/plugins/api.js'
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
})
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested'])
const emits = defineEmits(['closeRequested']);
const fileInput = ref(null)
const selectedFile = ref(null)
const fileUrl = ref(props.creator?.bannerUrl)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const errorMessage = ref('')
const showCropper = ref(false)
const cropper = ref(null)
const isUploading = ref(false)
const uploadProgress = ref(0)
const fileInput = ref(null);
const selectedFile = ref(null);
const fileUrl = ref(props.creator?.bannerUrl);
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png';
const errorMessage = ref('');
const showCropper = ref(false);
const cropper = ref(null);
const isUploading = ref(false);
const uploadProgress = ref(0);
// Get translations for this component
const { t } = useI18n()
// Get translations for this component
const { t } = useI18n();
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires
fileInput.value.click()
}
}
const onFileSelected = (event) => {
const file = event.target.files[0]
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
const applyCrop = () => {
if (!cropper.value) return
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
const client = useClient()
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await client.post(
`/api/creators/${props.creator.id}/banner`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click();
}
)
};
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`
fileUrl.value = props.creator.bannerUrl
emits('closeRequested')
} catch (error) {
console.error(error)
errorMessage.value = t('errors.imageUpload')
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const onFileSelected = event => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
} else {
selectedFile.value = null;
fileUrl.value = null;
showCropper.value = false;
}
};
const cancel = () => {
showCropper.value = false
// Reset to original state if we were editing
if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
}
emits('closeRequested')
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true;
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput();
}
};
// Add drop handler
const handleDrop = (event) => {
const file = event.dataTransfer.files[0]
if (file && file.type.startsWith('image/')) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => {
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(`/api/creators/${props.creator.id}/banner`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`;
fileUrl.value = props.creator.bannerUrl;
emits('closeRequested');
} catch (error) {
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator?.bannerUrl) {
fileUrl.value = props.creator.bannerUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script>
<style scoped>
.card-text {
@apply font-sans text-lg;
}
.card-text {
@apply font-sans text-lg;
}
.image-preview-container {
@apply mb-5;
@apply w-full;
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
@apply rounded-lg;
@apply relative;
@apply cursor-pointer;
@apply border-2;
@apply border-dashed;
@apply border-gray-300;
@apply hover:border-gray-500;
@apply transition-colors;
@apply duration-200;
}
.image-preview-container {
@apply mb-5;
@apply w-full;
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
@apply rounded-lg;
@apply relative;
@apply cursor-pointer;
@apply border-2;
@apply border-dashed;
@apply border-gray-300;
@apply hover:border-gray-500;
@apply transition-colors;
@apply duration-200;
}
.preview-image {
@apply w-full;
@apply aspect-[4/1];
@apply object-cover;
}
.preview-image {
@apply w-full;
@apply aspect-[4/1];
@apply object-cover;
}
.edit-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.edit-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.image-preview-container:hover .edit-overlay {
@apply bg-opacity-30;
}
.image-preview-container:hover .edit-overlay {
@apply bg-opacity-30;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.image-preview-container:hover .edit-text {
@apply opacity-100;
}
.image-preview-container:hover .edit-text {
@apply opacity-100;
}
.cropper-wrapper {
@apply mb-5;
@apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
}
.cropper-wrapper {
@apply mb-5;
@apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
}
.file-input-container {
@apply flex;
@apply justify-center;
@apply items-center;
@apply w-full;
}
.file-input-container {
@apply flex;
@apply justify-center;
@apply items-center;
@apply w-full;
}
.choose-file-button {
@apply px-4;
@apply py-2;
@apply primary;
@apply rounded-lg;
@apply cursor-pointer;
}
.choose-file-button {
@apply px-4;
@apply py-2;
@apply primary;
@apply rounded-lg;
@apply cursor-pointer;
}
.error-message {
@apply text-red-500;
@apply mt-2;
@apply text-center;
@apply font-medium;
}
.error-message {
@apply text-red-500;
@apply mt-2;
@apply text-center;
@apply font-medium;
}
:deep(.banner-stencil) {
@apply border-2;
@apply border-white;
}
:deep(.banner-stencil) {
@apply border-2;
@apply border-white;
}
:deep(.cropper) {
@apply max-h-full;
}
:deep(.cropper) {
@apply max-h-full;
}
.loading-spinner {
@apply inline-block;
@apply w-4;
@apply h-4;
@apply mr-2;
@apply border-2;
@apply border-white;
@apply border-t-transparent;
@apply rounded-full;
animation: spin 1s linear infinite;
}
.loading-spinner {
@apply inline-block;
@apply w-4;
@apply h-4;
@apply mr-2;
@apply border-2;
@apply border-white;
@apply border-t-transparent;
@apply rounded-full;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<i18n>
{
"en": {
"title": "Banner Editor",
"description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).",
"chooseImage": "Choose an image",
"clickToEdit": "Click to edit",
"uploading": "Uploading"
},
"fr": {
"title": "Éditeur de bannière",
"description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).",
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement"
},
"es": {
"title": "Editor de banner",
"description": "Sube o edita tu imagen de banner de perfil. El tamaño recomendado es de 1024x256 píxeles (ratio 4:1).",
"chooseImage": "Elegir una imagen",
"clickToEdit": "Haga clic para editar",
"uploading": "Subiendo"
}
"en": {
"title": "Banner Editor",
"description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).",
"chooseImage": "Choose an image",
"clickToEdit": "Click to edit",
"uploading": "Uploading"
},
"fr": {
"title": "Éditeur de bannière",
"description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).",
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement"
}
}
</i18n>

View File

@@ -1,144 +1,131 @@
<script setup>
import {computed, ref} from 'vue'
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
import {useClient} from "@/plugins/api.js";
import {useRouter, useRoute} from "vue-router";
import NameEditor from "@/views/creators/NameEditor.vue";
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useClient } from '@/plugins/api.js';
import { useRoute, useRouter } from 'vue-router';
import NameEditor from '@/views/creators/NameEditor.vue';
import { useI18n } from 'vue-i18n';
const creatorName = ref('');
const creatorNameReservationId = ref(undefined);
const canSave = computed(() => creatorNameReservationId.value !== undefined)
const creatorName = ref('');
const creatorNameReservationId = ref(undefined);
const canSave = computed(() => creatorNameReservationId.value !== undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const isOperationPending = ref(false);
const errorMessage = ref('');
const router = useRouter();
const route = useRoute();
const creatorProfileStore = useCreatorProfileStore();
const userProfileStore = useUserProfileStore();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const creatorProfileStore = useCreatorProfileStore();
const userProfileStore = useUserProfileStore();
const { t } = useI18n();
function handleCreatorNameReservationIdChanged($event) {
creatorNameReservationId.value = $event
}
function cancel () {
// if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl
if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl)
return
}
// otherwise just go back one step in history
router.back()
}
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
async function createAccount() {
try {
isOperationPending.value = true;
const client = useClient();
errorMessage.value = '';
await client.post('/api/creators', {
creatorId: userProfileStore.user.id,
slugReservationId: creatorNameReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
await router.push(`/@${creatorProfileStore.creator.slug}`);
} catch (error) {
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
function handleCreatorNameReservationIdChanged($event) {
creatorNameReservationId.value = $event;
}
} finally {
isOperationPending.value = false;
}
}
function cancel() {
// if a returnUrl querystring was supplied, prefer it
const returnUrl = route.query.returnUrl;
if (typeof returnUrl === 'string' && returnUrl.length) {
router.push(returnUrl);
return;
}
// otherwise just go back one step in history
router.back();
}
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
async function createAccount() {
try {
isOperationPending.value = true;
const client = useClient();
errorMessage.value = '';
await client.post('/api/creators', {
creatorId: userProfileStore.user.id,
slugReservationId: creatorNameReservationId.value,
});
await creatorProfileStore.fetchCreatorProfile();
await router.push(`/@${creatorProfileStore.creator.slug}`);
} catch (error) {
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isOperationPending.value = false;
}
}
</script>
<template>
<div class="container">
<div class="card">
<div class="container">
<div class="card">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<name-editor
v-model:name="creatorName"
:creator-name-reservation-id="creatorNameReservationId"
@update:creator-name-reservation-id="handleCreatorNameReservationIdChanged"
></name-editor>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button
class="primary"
:disabled="!canSave || isOperationPending"
@click="createAccount">
{{ t('create') }}
</button>
</div>
<div class="card-content">
<name-editor
v-model:name="creatorName"
:creator-name-reservation-id="creatorNameReservationId"
@update:creator-name-reservation-id="handleCreatorNameReservationIdChanged"
></name-editor>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isOperationPending"
class="primary"
@click="createAccount"
>
{{ t('create') }}
</button>
</div>
</div>
</div>
</div>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
</template>
<style scoped>
.container {
@apply min-h-screen w-full;
@apply flex items-center justify-center;
}
.container {
@apply min-h-screen w-full;
@apply flex items-center justify-center;
}
</style>
<i18n>
{
"en": {
"title": "Create your Hutopy",
"cancel": "Cancel",
"create": "Create my page",
"errors": {
"unexpected": "An unexpected error occurred"
"en": {
"title": "Create your Hutopy",
"cancel": "Cancel",
"create": "Create my page",
"errors": {
"unexpected": "An unexpected error occurred"
}
},
"fr": {
"title": "Créez votre Hutopy",
"cancel": "Annuler",
"create": "Créer ma page",
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
}
},
"fr": {
"title": "Créez votre Hutopy",
"cancel": "Annuler",
"create": "Créer ma page",
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"title": "Crea tu Hutopy",
"cancel": "Cancelar",
"create": "Crear mi página",
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

@@ -1,64 +1,69 @@
<template>
<div class="creator-home">
<!-- Content sections container -->
<div class="content-sections">
<!-- Donation Section -->
<div v-if="brandingStore.value?.acceptDonation" class="section sm:hidden">
<DonationButton :creator-id="brandingStore.value?.id" :creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
:on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id" />
</div>
<!-- About Creator Section -->
<div class="section">
<AboutCreator />
</div>
<div class="creator-home">
<!-- Content sections container -->
<div class="content-sections">
<!-- Donation Section -->
<div
v-if="brandingStore.value?.acceptDonation"
class="section sm:hidden"
>
<DonationButton
:creator-id="brandingStore.value?.id"
:creator-name="brandingStore.value?.name"
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
:on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id"
/>
</div>
<!-- About Creator Section -->
<div class="section">
<AboutCreator />
</div>
</div>
</div>
</div>
</template>
<script setup>
import AboutCreator from './AboutCreator.vue';
import DonationButton from "@/views/creators/DonationButton.vue";
import { useBrandingStore } from "@/stores/brandingStore.js";
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
import AboutCreator from './AboutCreator.vue';
import DonationButton from '@/views/creators/DonationButton.vue';
import { useBrandingStore } from '@/stores/brandingStore.js';
const brandingStore = useBrandingStore();
const baseURL = window.location.origin;
</script>
<style scoped>
.creator-home {
@apply w-full;
@apply p-5;
}
.creator-home {
@apply w-full;
@apply p-5;
}
.content-sections {
@apply flex flex-col;
@apply gap-5;
}
.content-sections {
@apply flex flex-col;
@apply gap-5;
}
.section {
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
}
.section {
@apply rounded-2xl;
@apply shadow-2xl;
@apply relative;
}
.section::before {
content: '';
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
background: linear-gradient(135deg, rgba(64, 64, 64, 1) 0%, rgba(64, 64, 64, 0) 20%, rgba(64, 64, 64, 0.5) 100%);
mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.section::before {
content: '';
@apply absolute inset-0;
@apply rounded-2xl;
@apply p-[1px];
background: linear-gradient(
135deg,
rgba(64, 64, 64, 1) 0%,
rgba(64, 64, 64, 0) 20%,
rgba(64, 64, 64, 0.5) 100%
);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
</style>
<i18n>
</i18n>

View File

@@ -141,26 +141,6 @@
"goHome": "Aller à l'accueil"
}
}
},
"es": {
"creator": {
"layout": {
"deletion": {
"pending": "Esta página de creador está pendiente de eliminación"
}
},
"notFound": {
"title": "Creador No Encontrado",
"message": "El creador '{creator}' no existe o puede haber sido eliminado.",
"goHome": "Ir al Inicio",
"goBack": "Volver"
},
"error": {
"title": "Algo Salió Mal",
"message": "Tenemos problemas para cargar esta página de creador. Por favor, inténtalo de nuevo más tarde.",
"goHome": "Ir al Inicio"
}
}
}
}
</i18n>

View File

@@ -1,78 +1,92 @@
<template>
<div class="relative" @mouseenter="showTint = isCurrentCreator" @mouseleave="showTint = false"
@click="isCurrentCreator && openBannerEditor()">
<div
class="relative"
@click="isCurrentCreator && openBannerEditor()"
@mouseenter="showTint = isCurrentCreator"
@mouseleave="showTint = false"
>
<div class="size-[110px] rounded-full border-4 border-hPrimary">
<img
:alt="t('logoAlt')"
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
class="rounded-full"
height="110px"
width="110px"
/>
</div>
<div class="size-[110px] rounded-full border-4 border-hPrimary">
<img :src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'" :alt="t('logoAlt')"
width="110px" height="110px" class="rounded-full" />
<!-- Tint Effect -->
<div
v-if="showTint"
:title="t('editLogo')"
class="absolute inset-0 cursor-pointer rounded-full bg-black/25"
>
<!-- Top-right Icon -->
<div
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
>
<v-icon
:icon="mdiPencil"
large
/>
</div>
</div>
</div>
<!-- Tint Effect -->
<div v-if="showTint" class="absolute inset-0 cursor-pointer rounded-full bg-black/25" :title="t('editLogo')">
<!-- Top-right Icon -->
<div
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg">
<v-icon large :icon="mdiPencil" />
</div>
</div>
</div>
<v-dialog v-model="isDialogOpen" max-width="800px">
<template #default="{ close }">
<creator-logo-editor :creator="brandingStore?.value"
@closeRequested="() => isDialogOpen = false"></creator-logo-editor>
</template>
</v-dialog>
<v-dialog
v-model="isDialogOpen"
max-width="800px"
>
<template #default="{ close }">
<creator-logo-editor
:creator="brandingStore?.value"
@closeRequested="() => (isDialogOpen = false)"
></creator-logo-editor>
</template>
</v-dialog>
</template>
<script setup>
import { useAuthStore } from "@/stores/authStore.js";
import { useBrandingStore } from "@/stores/brandingStore.js";
import CreatorLogoEditor from "@/views/creators/CreatorLogoEditor.vue";
import { computed, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { mdiPencil } from '@mdi/js';
import { useAuthStore } from '@/stores/authStore.js';
import { useBrandingStore } from '@/stores/brandingStore.js';
import CreatorLogoEditor from '@/views/creators/CreatorLogoEditor.vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { mdiPencil } from '@mdi/js';
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
const authStore = useAuthStore();
const brandingStore = useBrandingStore();
const { t } = useI18n();
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// State
const showTint = ref(false);
const isDialogOpen = ref(false);
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
// Methods
const openBannerEditor = () => {
isDialogOpen.value = true;
};
const isCurrentCreator = computed(() => {
return authStore.userId === brandingStore.value.id;
});
</script>
<style scoped>
.logo-image {
@apply border-4 border-solid border-hTertiary;
}
.logo-image {
@apply border-4 border-solid border-hTertiary;
}
</style>
<i18n>
{
"en": {
"logoAlt": "Creator logo",
"editLogo": "Edit logo"
},
"fr": {
"logoAlt": "Logo du créateur",
"editLogo": "Modifier le logo"
},
"es": {
"logoAlt": "Logo del creador",
"editLogo": "Editar logo"
}
"en": {
"logoAlt": "Creator logo",
"editLogo": "Edit logo"
},
"fr": {
"logoAlt": "Logo du créateur",
"editLogo": "Modifier le logo"
}
}
</i18n>

View File

@@ -1,397 +1,398 @@
<template>
<div class="card">
<div class="card-title">
{{ t('logoTitle') }}
</div>
<div class="card-content">
<p class="card-text">
{{ t('logoDescription') }}
</p>
<div class="file-input-container">
<input
type="file"
ref="fileInput"
accept="image/*"
class="hidden"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="showCropper" class="cropper-wrapper">
<Cropper
ref="cropper"
:src="fileUrl"
:aspect-ratio="1"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil'
}"
/>
</div>
<div v-else class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop">
<div class="circular-preview">
<img
:src="fileUrl || fallbackUrl"
:alt="t('preview')"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
<div class="card">
<div class="card-title">
{{ t('logoTitle') }}
</div>
</div>
</div>
<div class="card-actions">
<button class="secondary"
@click="cancel"
:disabled="isUploading">
{{ t('cancel') }}
</button>
<button class="primary"
@click="showCropper ? applyCrop() : publish()"
:disabled="!selectedFile || isUploading">
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
<div class="card-content">
<p class="card-text">
{{ t('logoDescription') }}
</p>
<div class="file-input-container">
<input
ref="fileInput"
accept="image/*"
class="hidden"
type="file"
@change="onFileSelected"
/>
<button
class="choose-file-button"
@click="triggerFileInput"
>
{{ t('chooseImage') }}
</button>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<div
v-if="showCropper"
class="cropper-wrapper"
>
<Cropper
ref="cropper"
:aspect-ratio="1"
:src="fileUrl"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil',
}"
/>
</div>
<div
v-else
class="image-preview-container"
@click="startEditing"
@dragover.prevent
@drop.prevent="handleDrop"
>
<div class="circular-preview">
<img
:alt="t('preview')"
:src="fileUrl || fallbackUrl"
class="preview-image"
/>
<div class="edit-overlay">
<span class="edit-text">{{ t('clickToEdit') }}</span>
</div>
</div>
</div>
</div>
<div class="card-actions">
<button
:disabled="isUploading"
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!selectedFile || isUploading"
class="primary"
@click="showCropper ? applyCrop() : publish()"
>
<template v-if="isUploading">
<span class="loading-spinner"></span>
{{ t('uploading') }} ({{ uploadProgress }}%)
</template>
<template v-else>
{{ showCropper ? t('apply') : t('save') }}
</template>
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue'
import {useClient} from '@/plugins/api.js'
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { CircleStencil, Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
})
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested'])
const emits = defineEmits(['closeRequested']);
const fileInput = ref(null)
const selectedFile = ref(null)
const fileUrl = ref(props.creator.portraitUrl)
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png'
const errorMessage = ref('')
const showCropper = ref(false)
const cropper = ref(null)
const isUploading = ref(false)
const uploadProgress = ref(0)
const fileInput = ref(null);
const selectedFile = ref(null);
const fileUrl = ref(props.creator.portraitUrl);
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png';
const errorMessage = ref('');
const showCropper = ref(false);
const cropper = ref(null);
const isUploading = ref(false);
const uploadProgress = ref(0);
const TARGET_WIDTH = 200
const TARGET_HEIGHT = 200
const TARGET_WIDTH = 200;
const TARGET_HEIGHT = 200;
const { t } = useI18n();
const { t } = useI18n();
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = '' // Reset the input value to ensure the change event fires
fileInput.value.click()
}
}
const onFileSelected = (event) => {
const file = event.target.files[0]
if (file) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
} else {
selectedFile.value = null
fileUrl.value = null
showCropper.value = false
}
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value)
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput()
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = (dataURL) => {
const arr = dataURL.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
const applyCrop = () => {
if (!cropper.value) return
const canvas = cropper.value.getResult().canvas
canvas.toBlob((blob) => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type
})
selectedFile.value = croppedFile
fileUrl.value = canvas.toDataURL()
showCropper.value = false
}, selectedFile.value.type)
}
const client = useClient()
const publish = async () => {
if (!selectedFile.value || isUploading.value) return
try {
isUploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await client.post(
`/api/creators/${props.creator.id}/logo`,
formData,
{
onUploadProgress: (progressEvent) => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
fileInput.value.click();
}
)
};
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
}
emits('closeRequested')
} catch (error) {
console.error(error)
errorMessage.value = t('errors.imageUpload')
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const onFileSelected = event => {
const file = event.target.files[0];
if (file) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
} else {
selectedFile.value = null;
fileUrl.value = null;
showCropper.value = false;
}
};
const cancel = () => {
showCropper.value = false
// Reset to original state if we were editing
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
}
emits('closeRequested')
}
const startEditing = () => {
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
// Only try to load the image if it's a data URL (newly selected image)
const blob = dataURLtoBlob(fileUrl.value);
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
showCropper.value = true;
} else {
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
triggerFileInput();
}
};
// Add drop handler
const handleDrop = (event) => {
const file = event.dataTransfer.files[0]
if (file && file.type.startsWith('image/')) {
selectedFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
fileUrl.value = e.target.result
showCropper.value = true
}
reader.readAsDataURL(file)
}
}
// Helper function to convert data URL to blob
const dataURLtoBlob = dataURL => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
const applyCrop = () => {
if (!cropper.value) return;
const canvas = cropper.value.getResult().canvas;
canvas.toBlob(blob => {
const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type,
});
selectedFile.value = croppedFile;
fileUrl.value = canvas.toDataURL();
showCropper.value = false;
}, selectedFile.value.type);
};
const client = useClient();
const publish = async () => {
if (!selectedFile.value || isUploading.value) return;
try {
isUploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await client.post(`/api/creators/${props.creator.id}/logo`, formData, {
onUploadProgress: progressEvent => {
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
},
});
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`;
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl;
}
emits('closeRequested');
} catch (error) {
console.error(error);
errorMessage.value = t('errors.imageUpload');
} finally {
isUploading.value = false;
uploadProgress.value = 0;
}
};
const cancel = () => {
showCropper.value = false;
// Reset to original state if we were editing
if (props.creator.portraitUrl) {
fileUrl.value = props.creator.portraitUrl;
selectedFile.value = null;
} else {
fileUrl.value = fallbackUrl;
selectedFile.value = null;
}
emits('closeRequested');
};
// Add drop handler
const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedFile.value = file;
const reader = new FileReader();
reader.onload = e => {
fileUrl.value = e.target.result;
showCropper.value = true;
};
reader.readAsDataURL(file);
}
};
</script>
<style scoped>
.card-text {
@apply font-sans text-lg;
}
.card-text {
@apply font-sans text-lg;
}
.image-preview-container {
@apply mb-5;
@apply w-full;
@apply flex;
@apply justify-center;
@apply items-center;
@apply border-2;
@apply border-dashed;
@apply border-gray-300;
@apply hover:border-gray-500;
@apply transition-colors;
@apply duration-200;
@apply rounded-lg;
@apply p-4;
}
.image-preview-container {
@apply mb-5;
@apply w-full;
@apply flex;
@apply justify-center;
@apply items-center;
@apply border-2;
@apply border-dashed;
@apply border-gray-300;
@apply hover:border-gray-500;
@apply transition-colors;
@apply duration-200;
@apply rounded-lg;
@apply p-4;
}
.circular-preview {
@apply w-[200px];
@apply h-[200px];
@apply rounded-full;
@apply overflow-hidden;
@apply border-2;
@apply border-gray-200;
@apply relative;
@apply cursor-pointer;
}
.circular-preview {
@apply w-[200px];
@apply h-[200px];
@apply rounded-full;
@apply overflow-hidden;
@apply border-2;
@apply border-gray-200;
@apply relative;
@apply cursor-pointer;
}
.preview-image {
@apply w-full;
@apply h-full;
@apply object-cover;
}
.preview-image {
@apply w-full;
@apply h-full;
@apply object-cover;
}
.edit-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.edit-overlay {
@apply absolute;
@apply inset-0;
@apply flex;
@apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.circular-preview:hover .edit-overlay {
@apply bg-opacity-30;
}
.circular-preview:hover .edit-overlay {
@apply bg-opacity-30;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.circular-preview:hover .edit-text {
@apply opacity-100;
}
.circular-preview:hover .edit-text {
@apply opacity-100;
}
.cropper-wrapper {
@apply mb-5;
@apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
}
.cropper-wrapper {
@apply mb-5;
@apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden;
}
.file-input-container {
@apply flex;
@apply justify-center;
@apply items-center;
@apply w-full;
}
.file-input-container {
@apply flex;
@apply justify-center;
@apply items-center;
@apply w-full;
}
.choose-file-button {
@apply px-4;
@apply py-2;
@apply primary;
@apply rounded-lg;
@apply cursor-pointer;
}
.choose-file-button {
@apply px-4;
@apply py-2;
@apply primary;
@apply rounded-lg;
@apply cursor-pointer;
}
.error-message {
@apply text-red-500;
@apply mt-2;
@apply text-center;
@apply font-medium;
}
.error-message {
@apply text-red-500;
@apply mt-2;
@apply text-center;
@apply font-medium;
}
:deep(.circle-stencil) {
@apply border-2;
@apply border-white;
@apply rounded-full;
}
:deep(.circle-stencil) {
@apply border-2;
@apply border-white;
@apply rounded-full;
}
:deep(.cropper) {
@apply max-h-full;
}
:deep(.cropper) {
@apply max-h-full;
}
:deep(.cropper__stencil) {
@apply rounded-full;
}
:deep(.cropper__stencil) {
@apply rounded-full;
}
.loading-spinner {
@apply inline-block;
@apply w-4;
@apply h-4;
@apply mr-2;
@apply border-2;
@apply border-white;
@apply border-t-transparent;
@apply rounded-full;
animation: spin 1s linear infinite;
}
.loading-spinner {
@apply inline-block;
@apply w-4;
@apply h-4;
@apply mr-2;
@apply border-2;
@apply border-white;
@apply border-t-transparent;
@apply rounded-full;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<i18n>
{
"en": {
"logoTitle": "Edit Logo",
"logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.",
"chooseImage": "Choose Image",
"clickToEdit": "Click to edit",
"uploading": "Uploading"
},
"fr": {
"logoTitle": "Modifier le logo",
"logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.",
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement"
},
"es": {
"logoTitle": "Editar logo",
"logoDescription": "Elige una imagen de logo para tu página de creador. La imagen se recortará en círculo.",
"chooseImage": "Elegir imagen",
"clickToEdit": "Haz clic para editar",
"uploading": "Subiendo"
}
"en": {
"logoTitle": "Edit Logo",
"logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.",
"chooseImage": "Choose Image",
"clickToEdit": "Click to edit",
"uploading": "Uploading"
},
"fr": {
"logoTitle": "Modifier le logo",
"logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.",
"chooseImage": "Choisir une image",
"clickToEdit": "Cliquez pour modifier",
"uploading": "Téléchargement"
}
}
</i18n>

View File

@@ -1,83 +1,74 @@
<template>
<button
class="secondary donation-action"
@click="openDonationDialog()"
>
{{ t('creator.donation.isupport') }}
</button>
<button
class="secondary donation-action"
@click="openDonationDialog()"
>
{{ t('creator.donation.isupport') }}
</button>
<DonationDialog
ref="donationDialogRef"
:creator-id="creatorId"
:creator-name="creatorName"
:on-success-url="onSuccessUrl"
:on-cancelled-url="onCancelledUrl"
:icon-color-class="iconColorClass"
@close="handleDialogClose"
/>
<DonationDialog
ref="donationDialogRef"
:creator-id="creatorId"
:creator-name="creatorName"
:icon-color-class="iconColorClass"
:on-cancelled-url="onCancelledUrl"
:on-success-url="onSuccessUrl"
@close="handleDialogClose"
/>
</template>
<script setup>
import {ref} from 'vue';
import {useI18n} from 'vue-i18n';
import DonationDialog from './DonationDialog.vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import DonationDialog from './DonationDialog.vue';
const {t} = useI18n();
const { t } = useI18n();
const props = defineProps({
creatorId: {default: 'missing-creator-id', required: true},
creatorName: {default: 'missing-creator-name', required: true},
onSuccessUrl: {default: 'missing-on-success-u', required: true},
onCancelledUrl: {default: 'missing-on-cancelled-url', required: true},
iconColorClass: {default: 'text-black'},
});
const props = defineProps({
creatorId: { default: 'missing-creator-id', required: true },
creatorName: { default: 'missing-creator-name', required: true },
onSuccessUrl: { default: 'missing-on-success-u', required: true },
onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
iconColorClass: { default: 'text-black' },
});
const donationDialogRef = ref(null);
const donationDialogRef = ref(null);
function openDonationDialog() {
donationDialogRef.value.openDonationDialog();
}
function openDonationDialog() {
donationDialogRef.value.openDonationDialog();
}
function handleDialogClose() {
// Handle any cleanup or additional logic when dialog closes
}
function handleDialogClose() {
// Handle any cleanup or additional logic when dialog closes
}
</script>
<style scoped>
.donation-action {
@apply bg-hutopyPrimary text-hOnPrimary;
@apply hover:bg-hutopySecondary;
@apply w-fit place-self-center;
@apply h-12;
@apply rounded-2xl w-full;
@apply font-sans font-semibold text-lg;
}
.donation-action {
@apply bg-hutopyPrimary text-hOnPrimary;
@apply hover:bg-hutopySecondary;
@apply w-fit place-self-center;
@apply h-12;
@apply rounded-2xl w-full;
@apply font-sans font-semibold text-lg;
}
</style>
<i18n>
{
"en": {
"creator": {
"donation": {
"isupport": "I Support"
}
"en": {
"creator": {
"donation": {
"isupport": "I Support"
}
}
},
"fr": {
"creator": {
"donation": {
"isupport": "Je Soutiens"
}
}
}
},
"fr": {
"creator": {
"donation": {
"isupport": "Je Soutiens"
}
}
},
"es": {
"creator": {
"donation": {
"isupport": "Apoyo"
}
}
}
}
</i18n>

View File

@@ -183,24 +183,6 @@
}
}
}
},
"es": {
"common": {
"cancel": "Cancelar"
},
"creator": {
"donation": {
"isupport": "Apoyo",
"amount": "Cantidad ($)",
"message": "Mensaje (opcional)",
"send": "Enviar",
"processing": "Procesando...",
"errors": {
"payment": "Ocurrió un error durante el procesamiento del pago",
"invalidAmount": "Por favor ingrese un monto válido"
}
}
}
}
}
</i18n>
</i18n>

View File

@@ -1,220 +1,223 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import { v7 } from "uuid";
import { useClient } from "@/plugins/api.js";
import { useI18n } from 'vue-i18n';
import config from '@/config';
import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { v7 } from 'uuid';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import config from '@/config';
import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
const props = defineProps({
name: {
required: true
},
creatorNameReservationId: {
required: true
},
originalSlug: {
type: String,
default: null
}
});
const props = defineProps({
name: {
required: true,
},
creatorNameReservationId: {
required: true,
},
originalSlug: {
type: String,
default: null,
},
});
const emits = defineEmits([
'update:name',
'update:creatorNameReservationId'
]);
const emits = defineEmits(['update:name', 'update:creatorNameReservationId']);
const name = ref(props.name);
const { t } = useI18n();
const name = ref(props.name);
const { t } = useI18n();
const isOperationPending = ref(false);
const reservationState = ref(null);
const validationError = ref('');
const isOperationPending = ref(false);
const reservationState = ref(null);
const validationError = ref('');
// Use the reservationId from props if provided, otherwise generate a new one
const reservationId = ref(props.creatorNameReservationId || v7());
// Use the reservationId from props if provided, otherwise generate a new one
const reservationId = ref(props.creatorNameReservationId || v7());
// Check if the current name is the same as the original slug
const isCurrentSlug = computed(() => {
return props.originalSlug && name.value === props.originalSlug;
});
// Check if the current name is the same as the original slug
const isCurrentSlug = computed(() => {
return props.originalSlug && name.value === props.originalSlug;
});
// Base URL for display
const baseUrl = computed(() => `${config.baseUrl}/@`);
// Base URL for display
const baseUrl = computed(() => `${config.baseUrl}/@`);
// Validation function for the slug
const validateSlug = (slug) => {
if (!slug) {
validationError.value = t('creator.name.errors.required');
return false;
}
// Validation function for the slug
const validateSlug = slug => {
if (!slug) {
validationError.value = t('creator.name.errors.required');
return false;
}
// Only allow letters, numbers, and hyphens
const validSlugRegex = /^[a-zA-Z0-9-]+$/;
if (!validSlugRegex.test(slug)) {
validationError.value = t('creator.name.errors.invalid');
return false;
}
// Only allow letters, numbers, and hyphens
const validSlugRegex = /^[a-zA-Z0-9-]+$/;
if (!validSlugRegex.test(slug)) {
validationError.value = t('creator.name.errors.invalid');
return false;
}
validationError.value = '';
return true;
};
validationError.value = '';
return true;
};
// Ensure we emit the reservationId on mount if we generated a new one
onMounted(() => {
if (!props.creatorNameReservationId) {
emits('update:creatorNameReservationId', reservationId.value);
}
// Ensure we emit the reservationId on mount if we generated a new one
onMounted(() => {
if (!props.creatorNameReservationId) {
emits('update:creatorNameReservationId', reservationId.value);
}
// If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) {
reservationState.value = "reserved";
}
});
// If the name is the same as the original slug, set the reservation state to "reserved"
if (isCurrentSlug.value) {
reservationState.value = 'reserved';
}
});
// Request handling
let currentController = null;
let timeout = null;
let lastProcessedName = '';
// Request handling
let currentController = null;
let timeout = null;
let lastProcessedName = '';
const cancelCurrentRequest = () => {
if (currentController) {
currentController.abort();
currentController = null;
}
};
const cancelCurrentRequest = () => {
if (currentController) {
currentController.abort();
currentController = null;
}
};
const handleInput = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const currentName = name.value;
if (currentName === lastProcessedName) {
return; // Skip if we've already processed this exact name
}
const handleInput = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const currentName = name.value;
if (currentName === lastProcessedName) {
return; // Skip if we've already processed this exact name
}
// Validate the slug
if (!validateSlug(currentName)) {
reservationState.value = "unavailable";
return;
}
// Validate the slug
if (!validateSlug(currentName)) {
reservationState.value = 'unavailable';
return;
}
// If the name is the same as the original slug, set reservation state to "reserved"
if (props.originalSlug && currentName === props.originalSlug) {
reservationState.value = "reserved";
lastProcessedName = currentName;
emits('update:name', currentName);
return;
}
// If the name is the same as the original slug, set reservation state to "reserved"
if (props.originalSlug && currentName === props.originalSlug) {
reservationState.value = 'reserved';
lastProcessedName = currentName;
emits('update:name', currentName);
return;
}
checkNameAvailability(currentName);
}, 200);
};
checkNameAvailability(currentName);
}, 200);
};
const client = useClient();
const checkNameAvailability = async (nameToCheck) => {
if (!nameToCheck || nameToCheck.trim() === "") {
reservationState.value = null;
lastProcessedName = nameToCheck;
return;
}
const client = useClient();
const checkNameAvailability = async nameToCheck => {
if (!nameToCheck || nameToCheck.trim() === '') {
reservationState.value = null;
lastProcessedName = nameToCheck;
return;
}
// Cancel any ongoing request
cancelCurrentRequest();
// Cancel any ongoing request
cancelCurrentRequest();
try {
isOperationPending.value = true;
reservationState.value = "loading";
try {
isOperationPending.value = true;
reservationState.value = 'loading';
// Create a new request with cancellation token
const controller = new AbortController();
currentController = controller;
// Create a new request with cancellation token
const controller = new AbortController();
currentController = controller;
await client.post(
`/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`,
{ reservationId: reservationId.value },
{ signal: controller.signal }
);
await client.post(
`/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`,
{ reservationId: reservationId.value },
{ signal: controller.signal }
);
// Only process the response if this is still the current request
if (currentController === controller) {
reservationState.value = "reserved";
lastProcessedName = nameToCheck;
emits('update:name', nameToCheck);
}
} catch (error) {
// Only process the error if this is still the current request and it's not an abort error
if (currentController && error.name !== 'AbortError') {
reservationState.value = "unavailable";
lastProcessedName = nameToCheck;
}
} finally {
if (currentController) {
isOperationPending.value = false;
}
}
};
// Cleanup on component unmount
onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
// Only process the response if this is still the current request
if (currentController === controller) {
reservationState.value = 'reserved';
lastProcessedName = nameToCheck;
emits('update:name', nameToCheck);
}
} catch (error) {
// Only process the error if this is still the current request and it's not an abort error
if (currentController && error.name !== 'AbortError') {
reservationState.value = 'unavailable';
lastProcessedName = nameToCheck;
}
} finally {
if (currentController) {
isOperationPending.value = false;
}
}
};
// Cleanup on component unmount
onUnmounted(() => {
cancelCurrentRequest();
clearTimeout(timeout);
});
</script>
<template>
<v-text-field variant="outlined" :label="t('creator.name.label')" v-model="name" @input="handleInput"
:error-messages="validationError">
<template #prepend-inner>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template>
<v-text-field
v-model="name"
:error-messages="validationError"
:label="t('creator.name.label')"
variant="outlined"
@input="handleInput"
>
<template #prepend-inner>
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
</template>
<template #append-inner>
<v-progress-circular v-if="reservationState === 'loading'" indeterminate size="24" width="3"
color="grey"></v-progress-circular>
<template #append-inner>
<v-progress-circular
v-if="reservationState === 'loading'"
color="grey"
indeterminate
size="24"
width="3"
></v-progress-circular>
<v-icon v-else-if="reservationState === 'reserved'" color="green" :icon="mdiCheckCircle" />
<v-icon v-else-if="reservationState === 'unavailable'" color="red" :icon="mdiCloseCircle" />
</template>
</v-text-field>
<v-icon
v-else-if="reservationState === 'reserved'"
:icon="mdiCheckCircle"
color="green"
/>
<v-icon
v-else-if="reservationState === 'unavailable'"
:icon="mdiCloseCircle"
color="red"
/>
</template>
</v-text-field>
</template>
<style scoped></style>
<i18n>
{
"en": {
"creator": {
"name": {
"label": "Your creator handle",
"errors": {
"required": "Creator handle is required",
"invalid": "Only letters, numbers, and hyphens are allowed"
"en": {
"creator": {
"name": {
"label": "Your creator handle",
"errors": {
"required": "Creator handle is required",
"invalid": "Only letters, numbers, and hyphens are allowed"
}
}
}
}
}
},
"fr": {
"creator": {
"name": {
"label": "Votre identifiant de créateur",
"errors": {
"required": "L'identifiant est requis",
"invalid": "Seules les lettres, chiffres et tirets sont autorisés"
},
"fr": {
"creator": {
"name": {
"label": "Votre identifiant de créateur",
"errors": {
"required": "L'identifiant est requis",
"invalid": "Seules les lettres, chiffres et tirets sont autorisés"
}
}
}
}
}
},
"es": {
"creator": {
"name": {
"label": "Tu identificador de creador",
"errors": {
"required": "El identificador es obligatorio",
"invalid": "Solo se permiten letras, números y guiones"
}
}
}
}
}
</i18n>

View File

@@ -4,37 +4,36 @@
<span class="capitalize text-3xl">
{{ brandingStore.value.name }}
</span>
<div v-show="brandingStore.value.verified"
class="text-blue mt-1"
:title="t('verified')">
<div
v-show="brandingStore.value.verified"
:title="t('verified')"
class="text-blue mt-1"
>
<icon-account-verified></icon-account-verified>
</div>
</div>
<span class="capitalize text-lg">
{{ brandingStore.value.title }}
</span>
</div>
</div>
</template>
<script setup>
import IconAccountVerified from "@/components/icons/IconAccountVerified.vue";
import {useBrandingStore} from "@/stores/brandingStore.js";
import { useI18n } from 'vue-i18n';
import IconAccountVerified from '@/components/icons/IconAccountVerified.vue';
import { useBrandingStore } from '@/stores/brandingStore.js';
import { useI18n } from 'vue-i18n';
const brandingStore = useBrandingStore();
const { t } = useI18n();
const brandingStore = useBrandingStore();
const { t } = useI18n();
</script>
<i18n>
{
"en": {
"verified": "Verified Account"
},
"fr": {
"verified": "Compte vérifié"
},
"es": {
"verified": "Cuenta verificada"
}
"en": {
"verified": "Verified Account"
},
"fr": {
"verified": "Compte vérifié"
}
}
</i18n>
</i18n>

View File

@@ -75,13 +75,6 @@
"usernameDefault": "Le créateur",
"receipt": "Un reçu a été envoyé à votre email.",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "¡{creatorName} te agradece!",
"message": "Su pago ha sido procesado con éxito.",
"usernameDefault": "El creador",
"receipt": "Se ha enviado un recibo a su correo electrónico.",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>

View File

@@ -46,12 +46,6 @@
"message": "Nous n'avons pas pu traiter votre paiement.",
"retry": "Réessayer",
"returnToCreator": "Retourner à la page du créateur"
},
"es": {
"title": "Pago fallido",
"message": "No pudimos procesar su pago.",
"retry": "Intentar de nuevo",
"returnToCreator": "Volver a la página del creador"
}
}
</i18n>

View File

@@ -1,130 +1,139 @@
<script setup>
import Instagram from "@/views/svg/Instagram.vue";
import Facebook from "@/views/svg/Facebook.vue";
import X from "@/views/svg/X.vue";
import { useI18n } from 'vue-i18n';
import Instagram from '@/views/svg/Instagram.vue';
import Facebook from '@/views/svg/Facebook.vue';
import X from '@/views/svg/X.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { t } = useI18n();
</script>
<template>
<footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials">
<a
href="https://www.facebook.com/profile.php?id=61556819217561"
target="_blank"
>
<facebook class="social-icon"></facebook>
</a>
<a
href="https://www.instagram.com/hutopy.inc/"
target="_blank"
>
<instagram class="social-icon"></instagram>
</a>
<a
href="https://x.com/Hutopyinc/"
target="_blank"
>
<x class="social-icon"></x>
</a>
</div>
<footer class="flex flex-col gap-10 pt-7 pb-10">
<div class="footer-socials">
<a href="https://www.facebook.com/profile.php?id=61556819217561" target="_blank">
<facebook class="social-icon"></facebook>
</a>
<a href="https://www.instagram.com/hutopy.inc/" target="_blank">
<instagram class="social-icon"></instagram>
</a>
<a href="https://x.com/Hutopyinc/" target="_blank">
<x class="social-icon"></x>
</a>
</div>
<div class="footer-links">
<router-link to="/documents/helpandcontact"
class="link">
{{ t('footer.helpandcontact') }}
</router-link>
<router-link to="/documents/faq"
class="link">
{{ t('footer.faq') }}
</router-link>
<router-link to="/documents/termsandconditions"
class="link">
{{ t('footer.termsandconditions') }}
</router-link>
<router-link to="/documents/contentpolicy"
class="link">
{{ t('footer.contentpolicy') }}
</router-link>
<router-link to="/documents/about"
class="link">
{{ t('footer.about') }}
</router-link>
<router-link to="/documents/pricing"
class="link">
{{ t('footer.pricing') }}
</router-link>
</div>
<div class="footer-copyright">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
</div>
</footer>
<div class="footer-links">
<router-link
class="link"
to="/documents/helpandcontact"
>
{{ t('footer.helpandcontact') }}
</router-link>
<router-link
class="link"
to="/documents/faq"
>
{{ t('footer.faq') }}
</router-link>
<router-link
class="link"
to="/documents/guideforcreators"
>
{{ t('footer.creatorguide') }}
</router-link>
<router-link
class="link"
to="/documents/termsandconditions"
>
{{ t('footer.termsandconditions') }}
</router-link>
<router-link
class="link"
to="/documents/contentpolicy"
>
{{ t('footer.contentpolicy') }}
</router-link>
<router-link
class="link"
to="/documents/about"
>
{{ t('footer.about') }}
</router-link>
<router-link
class="link"
to="/documents/pricing"
>
{{ t('footer.pricing') }}
</router-link>
</div>
<div class="footer-copyright">
Hutopy &copy;{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
</div>
</footer>
</template>
<style scoped>
.footer-socials {
@apply flex flex-row justify-center;
@apply gap-10;
}
.footer-socials {
@apply flex flex-row justify-center;
@apply gap-10;
}
.footer-links {
@apply flex flex-row flex-wrap justify-center;
@apply gap-4 px-4;
}
.footer-links {
@apply flex flex-row flex-wrap justify-center;
@apply gap-4 px-4;
}
.footer-copyright {
@apply flex justify-center;
@apply text-hOnBackground tracking-widest font-sans text-sm;
}
.footer-copyright {
@apply flex justify-center;
@apply text-hOnBackground tracking-widest font-sans text-sm;
}
.social-icon {
@apply fill-current w-6 h-6;
@apply text-hOnBackground;
}
.link {
@apply text-hOnBackground;
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
.social-icon {
@apply fill-current w-6 h-6;
@apply text-hOnBackground;
}
.link {
@apply text-hOnBackground;
@apply tracking-widest font-sans text-sm;
@apply hover:text-gray-400;
}
</style>
<i18n>
{
"en": {
"footer": {
"helpandcontact": "Help & Contact",
"faq": "FAQ",
"creatorguide": "Creator Guide",
"termsandconditions": "Terms & Conditions",
"contentpolicy": "Content Policy",
"about": "About",
"pricing": "Pricing",
"allRightsReserved": "All Rights Reserved"
"en": {
"footer": {
"helpandcontact": "Help & Contact",
"faq": "FAQ",
"creatorguide": "Creator Guide",
"termsandconditions": "Terms & Conditions",
"contentpolicy": "Content Policy",
"about": "About",
"pricing": "Pricing",
"allRightsReserved": "All Rights Reserved"
}
},
"fr": {
"footer": {
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide du Créateur",
"termsandconditions": "Conditions Générales",
"contentpolicy": "Politique de Contenu",
"about": "À Propos",
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
}
},
"fr": {
"footer": {
"helpandcontact": "Aide & Contact",
"faq": "FAQ",
"creatorguide": "Guide du Créateur",
"termsandconditions": "Conditions Générales",
"contentpolicy": "Politique de Contenu",
"about": "À Propos",
"pricing": "Tarifs",
"allRightsReserved": "Tous Droits Réservés"
}
},
"es": {
"footer": {
"helpandcontact": "Ayuda y Contacto",
"faq": "Preguntas Frecuentes",
"creatorguide": "Guía del Creador",
"termsandconditions": "Términos y Condiciones",
"contentpolicy": "Política de Contenido",
"about": "Acerca de",
"pricing": "Precios",
"allRightsReserved": "Todos los Derechos Reservados"
}
}
}
</i18n>
</i18n>

View File

@@ -1,292 +1,309 @@
<script setup>
import Footer from "@/views/main/Footer.vue";
import { useI18n } from 'vue-i18n';
import Footer from '@/views/main/Footer.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { t } = useI18n();
</script>
<template>
<div>
<div>
<div class="pa-4 flex flex-col justify-center md:flex-row">
<div class="py-6">
<div>
<img alt="Hutopy Logo" class="md:h-44 logo-image sm:h-28 sm:mx-auto"
src="/images/hutopymedia/banners/hutopy.png">
</div>
</div>
<div class="flex flex-col space-y-3 header-btn">
<v-btn
class="text-white w-full sm:w-auto inscription-btn-header"
to="/login">
{{ t('inscription') }}
</v-btn>
<v-btn
class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator"
variant="outlined">
{{ t('createPage') }}
</v-btn>
</div>
</div>
</div>
<div class="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6">
<div class="support-text text-justify md:text-left">
<span class="text-white"> {{ t('slogan') }} </span><br>
</div>
<img alt="YourHutopy" class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png">
</div>
<div class="relative mt-10">
<div class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1">
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
>
<div class="text-md text-justify px-6 ">
{{ t('supportDescription') }}
</div>
<!-- <v-btn>Soutenir</v-btn> -->
</div>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
<v-btn
class="inscription-btn"
to="/login"
>
{{ t('signup') }}
</v-btn>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img alt="YourHutopy" class="w-full mb-6" src="/images/hutopymedia/homepage/votrehutopy.png">
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">{{ t('whatIsHutopy') }}</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyValues') }}
</p>
<div class="flex justify-center">
<v-btn
class="text-white mt-12 flex items-center justify-center round create-btn"
to="/create-creator"
>
{{ t('createPage') }}
</v-btn>
<div>
<div class="pa-4 flex flex-col justify-center md:flex-row">
<div class="py-6">
<div>
<img
alt="Hutopy Logo"
class="md:h-44 logo-image sm:h-28 sm:mx-auto"
src="/images/hutopymedia/banners/hutopy.png"
/>
</div>
</div>
<div class="flex flex-col space-y-3 header-btn">
<v-btn
class="text-white w-full sm:w-auto inscription-btn-header"
to="/login"
>
{{ t('inscription') }}
</v-btn>
<v-btn
class="w-full sm:w-auto inscription-btn-header-outlined"
to="/create-creator"
variant="outlined"
>
{{ t('createPage') }}
</v-btn>
</div>
</div>
</div>
</div>
<!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
<div><img alt="Grinding" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"></div>
<div><img alt="Microphone" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"></div>
<div><img alt="Girl VR" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"></div>
<div><img alt="Girl Army" class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"></div>
<div class="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6">
<div class="support-text text-justify md:text-left">
<span class="text-white">{{ t('support') }}</span>
<br />
<span class="text-white">{{ t('creators') }}</span>
<br />
<span class="text-white">{{ t('projects') }}</span>
<br />
<span class="text-white">{{ t('love') }}</span>
</div>
<img
alt="YourHutopy"
class="w-48 h-48 md:w-48 md:h-48 object-contain"
src="/images/hutopymedia/banners/heart.png"
/>
</div>
</div>
<div class="relative mt-10">
<div
class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1"
>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/hands.png"
/>
<div class="text-md text-justify px-6">
{{ t('supportDescription') }}
</div>
<!-- <v-btn>Soutenir</v-btn> -->
</div>
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
<img
alt="YourHutopy"
class="max-h-56 mx-auto"
src="/images/hutopymedia/homepage/brain.png"
/>
<div class="text-md text-justify px-6">
{{ t('creatorDescription') }}
</div>
<v-btn
class="inscription-btn"
to="/login"
>
{{ t('signup') }}
</v-btn>
</div>
</div>
</div>
<div class="max-w-5xl mx-auto px-6 py-8">
<div class="gap-8 items-start flex flex-col md:flex-row">
<!-- Section de texte -->
<div class="space-y-6">
<img
alt="YourHutopy"
class="w-full mb-6"
src="/images/hutopymedia/homepage/votrehutopy.png"
/>
<div class="space-y-4">
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('whatIsHutopy') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyDescription') }}
</p>
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
{{ t('hutopyValues') }}
</p>
<div class="flex justify-center">
<v-btn
class="text-white mt-12 flex items-center justify-center round create-btn"
to="/create-creator"
>
{{ t('createPage') }}
</v-btn>
</div>
</div>
</div>
<!-- Section droite : 4 images -->
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
<div>
<img
alt="Grinding"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/grinding.png"
/>
</div>
<div>
<img
alt="Microphone"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/sign.png"
/>
</div>
<div>
<img
alt="Girl VR"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlvr.png"
/>
</div>
<div>
<img
alt="Girl Army"
class="w-full h-auto object-cover rounded-2xl"
src="/images/hutopymedia/homepage/girlarmy.png"
/>
</div>
</div>
</div>
</div>
<Footer class="mt-10"></Footer>
</div>
<Footer class="mt-10"></Footer>
</div>
</template>
<style scoped>
.box-text {
color: #6A0164;
font-size: 30px;
font-weight: bold;
}
.box-text {
color: #6a0164;
font-size: 30px;
font-weight: bold;
}
.inscription-btn-header {
color: white;
background-color: #6A0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
.inscription-btn-header {
color: white;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
}
}
.inscription-btn-header-outlined {
color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
}
.inscription-btn-header-outlined {
color: #6A0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
.inscription-btn {
color: white;
background-color: #6a0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
}
.create-btn {
background-color: #6a0164;
font-size: 18px;
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
.inscription-btn {
color: white;
background-color: #6A0164;
font-size: 18px;
height: 40px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px;
}
.overlay p {
color: white;
font-size: 1.5rem;
text-align: center;
}
.create-btn {
background-color: #6A0164;
font-size: 18px;
height: 48px;
width: auto;
padding: 0 32px;
font-weight: bold;
border-radius: 10px
}
body {
background-color: #f4f4f4;
}
.overlay p {
color: white;
font-size: 1.5rem;
text-align: center;
}
.support-container {
display: flex;
justify-content: center; /* Centre le bloc horizontalement */
align-items: center; /* Centre le bloc verticalement (optionnel) */
}
body {
background-color: #F4F4F4;
}
.support-text {
font-size: 2.2rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
.support-container {
display: flex;
justify-content: center; /* Centre le bloc horizontalement */
align-items: center; /* Centre le bloc verticalement (optionnel) */
.support-text .highlight {
color: #6a0164; /* Remplacez par la couleur souhaitée */
font-weight: bold; /* Mettre en gras */
}
}
.highlight2 {
color: #b81286; /* Remplacez par la couleur souhaitée */
}
.support-text {
font-size: 2.2rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
.logo-image {
margin-left: auto;
}
.support-text .highlight {
color: #6A0164; /* Remplacez par la couleur souhaitée */
font-weight: bold; /* Mettre en gras */
}
@media (min-width: 640px) {
.header-btn {
margin-top: 25px;
margin-bottom: 25px;
}
.highlight2 {
color: #B81286; /* Remplacez par la couleur souhaitée */
.support-text {
font-size: 3rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
}
}
@media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
.logo-image {
margin-left: auto;
}
@media (min-width: 640px) {
.header-btn {
margin-top: 25px;
margin-bottom: 25px;
}
.support-text {
font-size: 3.0rem; /* Ajustez la taille du texte */
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
text-align: left; /* Alignement du texte à gauche */
font-weight: bold; /* Rend le texte gras */
}
}
@media (min-width: 768px) {
.header-btn {
margin-top: 60px;
}
.logo-image {
margin-right: 20px;
margin-left: 0;
}
}
.homepagetext {
color: white;
font-family: "Roboto", sans-serif;
}
.logo-image {
margin-right: 20px;
margin-left: 0;
}
}
.homepagetext {
color: white;
font-family: 'Roboto', sans-serif;
}
</style>
<i18n>
{
"en": {
"inscription": "Sign Up",
"createPage": "Create Page",
"support": "Support",
"creators": "Creators",
"projects": "Projects",
"love": "Love",
"supportText": "Support",
"supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.",
"create": "Create",
"creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.",
"signup": "Sign Up",
"whatIsHutopy": "What is Hutopy?",
"hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.",
"hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers."
},
"fr": {
"inscription": "S'inscrire",
"createPage": "Créer une Page",
"slogan": "Soutenez les projets qui vous tiennent à cœur",
"supportText": "Soutenir",
"supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.",
"create": "Créer",
"creatorDescription": "Créez votre propre page et construisez votre Hutopy.",
"signup": "S'inscrire",
"whatIsHutopy": "Qu'est-ce que Hutopy ?",
"hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.",
"hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutient."
},
"es": {
"inscription": "Registrarse",
"createPage": "Crear Página",
"support": "Apoyar",
"creators": "Creadores",
"projects": "Proyectos",
"love": "Pasión",
"supportText": "Apoyar",
"supportDescription": "Apoya a tus creadores favoritos y ayúdales a crecer. Tus contribuciones hacen una diferencia real en su viaje creativo.",
"create": "Crear",
"creatorDescription": "Crea tu propia página y comienza tu viaje creativo. Comparte tu pasión con el mundo y construye tu comunidad.",
"signup": "Registrarse",
"whatIsHutopy": "¿Qué es Hutopy?",
"hutopyDescription": "Hutopy es una plataforma que conecta a los creadores con su audiencia. Proporcionamos herramientas y funciones para ayudar a los creadores a monetizar su contenido y construir su comunidad.",
"hutopyValues": "Nuestros valores se centran en la creatividad, la comunidad y el apoyo. Creemos en empoderar a los creadores para perseguir sus pasiones y construir carreras sostenibles."
}
"en": {
"inscription": "Sign Up",
"createPage": "Create Page",
"support": "Support",
"creators": "Creators",
"projects": "Projects",
"love": "Love",
"supportText": "Support",
"supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.",
"create": "Create",
"creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.",
"signup": "Sign Up",
"whatIsHutopy": "What is Hutopy?",
"hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.",
"hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers."
},
"fr": {
"inscription": "S'inscrire",
"createPage": "Créer une Page",
"support": "Soutenir",
"creators": "Créateurs",
"projects": "Projets",
"love": "Passion",
"supportText": "Soutenir",
"supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.",
"create": "Créer",
"creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.",
"signup": "S'inscrire",
"whatIsHutopy": "Qu'est-ce que Hutopy ?",
"hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.",
"hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutien. Nous croyons en l'autonomisation des créateurs pour poursuivre leurs passions et construire des carrières durables."
}
}
</i18n>
</i18n>

View File

@@ -4,19 +4,17 @@
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useUserProfileStore } from '@/stores/userProfileStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useRoute } from 'vue-router';
import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
const { locale, t } = useI18n();
const languageStore = useLanguageStore();
const route = useRoute();
const userProfileStore = useUserProfileStore();
const creatorProfileStore = useCreatorProfileStore();
const authStore = useAuthStore();
function toggleLanguage() {
const languages = ['fr', 'en', 'es'];
const languages = ['fr', 'en'];
const currentIndex = languages.indexOf(locale.value);
const nextIndex = (currentIndex + 1) % languages.length;
languageStore.setLocale(languages[nextIndex]);
@@ -117,23 +115,20 @@
<style scoped>
.side-container {
@apply bg-hSurface text-hOnSurface;
@apply lg:fixed lg:max-h-screen;
@apply flex;
@apply lg:flex-col lg:w-64 lg:max-w-64;
@apply h-16 lg:h-screen;
@apply lg:border-r-2 lg:border-[#2d282d];
@apply max-h-screen;
@apply h-16;
}
.side-logo {
@apply flex flex-grow;
@apply items-center justify-start p-4;
@apply lg:items-start lg:justify-center lg:pt-4;
}
.side-menu {
@apply flex gap-4 p-6;
@apply items-center lg:items-stretch;
@apply flex-row-reverse lg:flex-col;
@apply items-center;
@apply flex-row-reverse;
}
.side-menu-portrait {
@@ -145,21 +140,20 @@
.side-menu-items {
@apply flex gap-2;
@apply flex-row;
@apply lg:w-full lg:flex-col;
}
.profile-label {
@apply ml-5;
@apply text-lg font-sans capitalize;
@apply font-semibold;
@apply hidden lg:inline;
@apply hidden;
@apply min-w-40 truncate;
}
.label {
@apply text-nowrap;
@apply ml-4;
@apply hidden lg:inline;
@apply hidden;
}
.menu-item-action {
@@ -193,14 +187,6 @@
"signIn": "Se Connecter",
"signOut": "Se Déconnecter"
}
},
"es": {
"sidebar": {
"myPage": "Mi Página",
"myProfile": "Mi Perfil",
"signIn": "Iniciar Sesión",
"signOut": "Cerrar Sesión"
}
}
}
</i18n>

View File

@@ -948,39 +948,6 @@
"phoneNumber": "Numéro de téléphone",
"title": "Titre",
"removeStripe": "Retirer Stripe"
},
"es": {
"personalInfo": "Información Personal",
"fullName": "Nombre Completo",
"alias": "Alias",
"email": "Correo Electrónico",
"changePassword": "Actualizar contraseña",
"creatorInfo": "Información del Creador",
"dangerZone": "Zona de Peligro",
"dangerZoneWarning": "Las acciones a continuación pueden tener impactos significativos en tu página de creador. Por favor, procede con precaución.",
"deleteWarning": "¿Estás seguro de que quieres eliminar tu página de creador? Esta acción no se puede deshacer.",
"restoreWarning": "¿Estás seguro de que quieres restaurar tu página de creador? Esto hará que tu página sea visible nuevamente.",
"deleteCreatorPage": "Eliminar Página de Creador",
"restoreCreatorPage": "Restaurar Página de Creador",
"stripeAccountId": "ID de Cuenta Stripe",
"socialNetworks": "Redes Sociales",
"handle": "Identificador del creador",
"qrCode": "Código QR",
"qrCodeDescription": "¡Imprime este código QR para compartir tu Hutopy con el mundo! Perfecto para tarjetas de presentación, redes sociales y materiales promocionales.",
"downloadQRCode": "Descargar Código QR",
"payment-information": "Información de Pago",
"stripeStatus": "Estado de Stripe",
"configured": "Configurado",
"notConfigured": "No Configurado",
"needsMoreInfo": "Requiere Más Información",
"pendingVerification": "Verificación Pendiente",
"continueStripeSetup": "Continuar Configuración de Stripe",
"reviewStripe": "Revisar Stripe",
"notSet": "No Establecido",
"configureStripe": "Connectar Stripe",
"phoneNumber": "Número de teléfono",
"title": "Título",
"removeStripe": "Eliminar Stripe"
}
}
</i18n>

View File

@@ -1,66 +1,58 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-content">
<v-text-field
v-model="alias"
:label="t('label')"
variant="outlined"
></v-text-field>
</div>
<div class="card-title">
{{ t('title') }}
<div class="card-actions">
<button
class="secondary"
@click="requestClose"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="requestSave"
>
{{ t('save') }}
</button>
</div>
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="alias"
:label="t('label')"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary"
@click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary"
@click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['alias'])
const emit = defineEmits(['close', 'save'])
const { t } = useI18n();
const props = defineProps(['alias']);
const emit = defineEmits(['close', 'save']);
const alias = ref(props.alias)
const alias = ref(props.alias);
const requestClose = () => emit('close')
const requestSave = () => emit('save', alias.value)
const requestClose = () => emit('close');
const requestSave = () => emit('save', alias.value);
</script>
<i18n>
{
"en": {
"title": "Alias",
"label": "Your alias"
},
"fr": {
"title": "Alias",
"label": "Votre alias"
},
"es": {
"title": "Alias",
"label": "Tu alias"
}
"en": {
"title": "Alias",
"label": "Your alias"
},
"fr": {
"title": "Alias",
"label": "Votre alias"
}
}
</i18n>

View File

@@ -1,162 +1,177 @@
<template>
<div class="card dialog">
<div class="card dialog">
<div class="card-title">
{{ t('changePassword') }}
</div>
<div class="card-title">
{{ t('changePassword') }}
<div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field
v-model="newPassword"
:hint="t('passwordRequirements')"
:label="t('newPassword')"
:type="showNewPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showNewPassword = !showNewPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
variant="outlined"
>
<template v-slot:append-inner>
<v-icon
:icon="showNewPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</v-text-field>
<div
v-if="errorMessage"
class="error-message mb-4"
>
{{ errorMessage }}
</div>
<div class="card-actions">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button
:disabled="isLoading"
class="primary"
@click="handleChangePassword"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
<div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field v-model="newPassword" :label="t('newPassword')" :type="showNewPassword ? 'text' : 'password'"
variant="outlined" required :hint="t('passwordRequirements')">
<template v-slot:append-inner>
<v-icon @click="showNewPassword = !showNewPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<v-text-field v-model="confirmPassword" :label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'" variant="outlined" required>
<template v-slot:append-inner>
<v-icon @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small"
:icon="showNewPassword ? mdiEyeOff : mdiEye" />
</template>
</v-text-field>
<div v-if="errorMessage" class="error-message mb-4">
{{ errorMessage }}
</div>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="handleChangePassword" :disabled="isLoading">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js';
import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const authStore = useAuthStore();
const emit = defineEmits(['closeRequested']);
const { t } = useI18n();
const authStore = useAuthStore();
const emit = defineEmits(['closeRequested']);
const newPassword = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorMessage = ref('');
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
const newPassword = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorMessage = ref('');
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
async function handleChangePassword() {
// Clear previous error
errorMessage.value = '';
async function handleChangePassword() {
// Clear previous error
errorMessage.value = '';
// Validate passwords match
if (newPassword.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Validate passwords match
if (newPassword.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Validate password length
if (newPassword.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
// Validate password length
if (newPassword.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
isLoading.value = true;
isLoading.value = true;
try {
// Pass empty string for current password since we're already authenticated
// This will use the set-password endpoint for OAuth users
await authStore.changePassword(newPassword.value);
try {
// Pass empty string for current password since we're already authenticated
// This will use the set-password endpoint for OAuth users
await authStore.changePassword(newPassword.value);
// Success - close dialog
emit('closeRequested');
// Success - close dialog
emit('closeRequested');
// You could also emit a success event if needed
// emit('success');
} catch (error) {
console.error('Failed to change password:', error);
// Use error message from response if available, or the error message itself, or fallback
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
} finally {
isLoading.value = false;
}
}
// You could also emit a success event if needed
// emit('success');
} catch (error) {
console.error('Failed to change password:', error);
// Use error message from response if available, or the error message itself, or fallback
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.dialog {
@apply max-w-md mx-auto;
}
.dialog {
@apply max-w-md mx-auto;
}
.error-message {
@apply text-red-500 text-sm mt-2;
}
.error-message {
@apply text-red-500 text-sm mt-2;
}
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
@apply z-10;
}
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
</style>
<i18n>
{
"en": {
"changePassword": "Update Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"passwordRequirements": "Password must be at least 8 characters",
"passwordDescription": "Updating your password allows you to log in directly with your email and password.",
"save": "Save",
"cancel": "Cancel",
"passwordsDoNotMatch": "New passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"passwordUpdateFailed": "Failed to update password. Please try again."
},
"fr": {
"changePassword": "Modifier le mot de passe",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.",
"save": "Enregistrer",
"cancel": "Annuler",
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
},
"es": {
"changePassword": "Actualizar contraseña",
"newPassword": "Nueva contraseña",
"confirmPassword": "Confirmar nueva contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"passwordDescription": "La actualización de su contraseña le permite iniciar sesión directamente con su correo electrónico y contraseña.",
"save": "Guardar",
"cancel": "Cancelar",
"passwordsDoNotMatch": "Las nuevas contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"passwordUpdateFailed": "Error al actualizar la contraseña. Por favor, inténtelo de nuevo."
}
"en": {
"changePassword": "Update Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"passwordRequirements": "Password must be at least 8 characters",
"passwordDescription": "Updating your password allows you to log in directly with your email and password.",
"save": "Save",
"cancel": "Cancel",
"passwordsDoNotMatch": "New passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"passwordUpdateFailed": "Failed to update password. Please try again."
},
"fr": {
"changePassword": "Modifier le mot de passe",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.",
"save": "Enregistrer",
"cancel": "Annuler",
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
}
}
</i18n>

View File

@@ -1,82 +1,78 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
v-model="email"
:label="t('label')"
variant="outlined"
></v-text-field>
</div>
<div class="card-content">
<v-text-field
v-model="email"
:label="t('label')"
variant="outlined"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useClient} from "@/plugins/api.js";
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
email: {
required: true,
type: String
}
});
const { t } = useI18n();
const props = defineProps({
email: {
required: true,
type: String,
},
});
const emits = defineEmits(['closeRequested']);
const emits = defineEmits(['closeRequested']);
const email = ref(props.email);
const email = ref(props.email);
const client = useClient();
const save = async () => {
try {
await client.post(
`/api/users/email`,
{
email: email.value
});
const client = useClient();
const save = async () => {
try {
await client.post(`/api/users/email`, {
email: email.value,
});
emits('closeRequested');
} catch (error) {
console.error(error);
}
};
emits('closeRequested');
} catch (error) {
console.error(error);
}
};
const cancel = () => {
emits('closeRequested');
};
const cancel = () => {
emits('closeRequested');
};
</script>
<i18n>
{
"en": {
"title": "Change your Email",
"label": "Your email"
},
"fr": {
"title": "Changez votre Courriel",
"label": "Votre email"
},
"es": {
"title": "Cambia tu correo electrónico",
"label": "Tu correo electrónico"
}
"en": {
"title": "Change your Email",
"label": "Your email"
},
"fr": {
"title": "Changez votre Courriel",
"label": "Votre email"
}
}
</i18n>

View File

@@ -1,70 +1,69 @@
<script setup>
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['firstname', 'lastname'])
const emit = defineEmits(['close', 'save'])
const { t } = useI18n();
const props = defineProps(['firstname', 'lastname']);
const emit = defineEmits(['close', 'save']);
const firstname = ref(props.firstname)
const lastname = ref(props.lastname)
const firstname = ref(props.firstname);
const lastname = ref(props.lastname);
const requestClose = () => emit('close')
const requestSave = () => emit('save', firstname.value, lastname.value)
const requestClose = () => emit('close');
const requestSave = () => emit('save', firstname.value, lastname.value);
</script>
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="firstname"
:label="t('firstname')"
></v-text-field>
</div>
<div class="card-content">
<v-text-field
v-model="firstname"
:label="t('firstname')"
variant="outlined"
></v-text-field>
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="lastname"
:label="t('lastname')"
></v-text-field>
</div>
<div class="card-content">
<v-text-field
v-model="lastname"
:label="t('lastname')"
variant="outlined"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary"
@click="requestClose">
{{ t('cancel') }}
</button>
<div class="card-actions">
<button
class="secondary"
@click="requestClose"
>
{{ t('cancel') }}
</button>
<button class="primary"
@click="requestSave">
{{ t('save') }}
</button>
<button
class="primary"
@click="requestSave"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<i18n>
{
"en": {
"title": "Full Name",
"firstname": "First Name",
"lastname": "Last Name"
},
"fr": {
"title": "Nom complet",
"firstname": "Prénom",
"lastname": "Nom"
},
"es": {
"title": "Nombre completo",
"firstname": "Nombre",
"lastname": "Apellido"
}
"en": {
"title": "Full Name",
"firstname": "First Name",
"lastname": "Last Name"
},
"fr": {
"title": "Nom complet",
"firstname": "Prénom",
"lastname": "Nom"
}
}
</i18n>

View File

@@ -1,174 +1,164 @@
<template>
<div class="card dialog">
<div class="card-title">{{ t('changeEmail') }}</div>
<div class="card-content">
<v-text-field
v-model="email"
class="w-full p-2"
:label="t('email')"
type="email"
variant="outlined"
:error-messages="emailErrors"
:rules="emailRules"
validate-on="blur"
/>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card dialog">
<div class="card-title">{{ t('changeEmail') }}</div>
<div class="card-content">
<v-text-field
v-model="email"
:error-messages="emailErrors"
:label="t('email')"
:rules="emailRules"
class="w-full p-2"
type="email"
validate-on="blur"
variant="outlined"
/>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="saveEmail" :disabled="!canSave || isLoading">
{{ t('save') }}
</button>
</div>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isLoading"
class="primary"
@click="saveEmail"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const { t } = useI18n();
const client = useClient();
const creatorProfileStore = useCreatorProfileStore();
const { t } = useI18n();
const client = useClient();
const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({
creator: {
type: Object,
required: true
}
});
const props = defineProps({
creator: {
type: Object,
required: true,
},
});
const email = ref(props.creator.presentation?.email || '');
const isLoading = ref(false);
const errorMessage = ref('');
const email = ref(props.creator.presentation?.email || '');
const isLoading = ref(false);
const errorMessage = ref('');
// Email validation
const isValidEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Email validation
const isValidEmail = email => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const emailRules = [
v => !!v || t('validation.emailRequired'),
v => isValidEmail(v) || t('validation.emailInvalid'),
];
const emailRules = [
v => !!v || t('validation.emailRequired'),
v => isValidEmail(v) || t('validation.emailInvalid'),
];
const emailErrors = computed(() => {
if (!email.value) {
return [t('validation.emailRequired')];
}
if (!isValidEmail(email.value)) {
return [t('validation.emailInvalid')];
}
return [];
});
const emailErrors = computed(() => {
if (!email.value) {
return [t('validation.emailRequired')];
}
if (!isValidEmail(email.value)) {
return [t('validation.emailInvalid')];
}
return [];
});
const canSave = computed(() => {
return email.value &&
isValidEmail(email.value) &&
email.value !== (props.creator.presentation?.email || '');
});
const canSave = computed(() => {
return email.value && isValidEmail(email.value) && email.value !== (props.creator.presentation?.email || '');
});
async function saveEmail() {
if (!props.creator.id) {
console.error("Creator ID is missing!");
return;
}
async function saveEmail() {
if (!props.creator.id) {
console.error('Creator ID is missing!');
return;
}
if (!canSave.value) {
return;
}
if (!canSave.value) {
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
try {
isLoading.value = true;
errorMessage.value = '';
// Save email
await client.post(
`/api/creators/${props.creator.id}/email`,
{
email: email.value.trim()
}
);
// Save email
await client.post(`/api/creators/${props.creator.id}/email`, {
email: email.value.trim(),
});
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
// Close dialog
emit('closeRequested');
} catch (error) {
console.error("Error saving email:", error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
// Close dialog
emit('closeRequested');
} catch (error) {
console.error('Error saving email:', error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
}
} finally {
isLoading.value = false;
}
}
const emit = defineEmits(['closeRequested']);
const emit = defineEmits(['closeRequested']);
</script>
<style scoped>
.dialog {
@apply max-w-md mx-auto;
}
.dialog {
@apply max-w-md mx-auto;
}
</style>
<i18n>
{
"en": {
"changeEmail": "Change Email",
"email": "Email",
"save": "Save",
"cancel": "Cancel",
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Please enter a valid email address"
"en": {
"changeEmail": "Change Email",
"email": "Email",
"save": "Save",
"cancel": "Cancel",
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Please enter a valid email address"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
},
"errors": {
"unexpected": "An unexpected error occurred"
"fr": {
"changeEmail": "Modifier l'email",
"email": "Email",
"save": "Enregistrer",
"cancel": "Annuler",
"validation": {
"emailRequired": "L'email est requis",
"emailInvalid": "Veuillez entrer une adresse email valide"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
}
},
"fr": {
"changeEmail": "Modifier l'email",
"email": "Email",
"save": "Enregistrer",
"cancel": "Annuler",
"validation": {
"emailRequired": "L'email est requis",
"emailInvalid": "Veuillez entrer une adresse email valide"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changeEmail": "Cambiar correo electrónico",
"email": "Correo electrónico",
"save": "Guardar",
"cancel": "Cancelar",
"validation": {
"emailRequired": "El correo electrónico es obligatorio",
"emailInvalid": "Por favor ingrese una dirección de correo electrónico válida"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

@@ -1,87 +1,82 @@
<script setup>
import {ref} from 'vue';
import {useClient} from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
creator: {
required: true
}
});
const { t } = useI18n();
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested']);
const emits = defineEmits(['closeRequested']);
const name = ref(props.creator.name);
const name = ref(props.creator.name);
const client = useClient();
const client = useClient();
async function save() {
try {
await client.post(
`/api/creators/${props.creator.id}/name`,
{
name: name.value
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);
}
);
}
props.creator.name = name.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
}
}
const cancel = () => {
emits('closeRequested');
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
v-model="name"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-content">
<v-text-field
v-model="name"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
"en": {
"title": "Change Name",
"label": "Your name"
},
"fr": {
"title": "Modifier le nom",
"label": "Votre nom"
},
"es": {
"title": "Cambiar nombre",
"label": "Tu nombre"
}
"en": {
"title": "Change Name",
"label": "Your name"
},
"fr": {
"title": "Modifier le nom",
"label": "Votre nom"
}
}
</i18n>

View File

@@ -1,246 +1,239 @@
<template>
<div class="card dialog">
<div class="card-title">{{ t('changePhoneNumber') }}</div>
<div class="card-content">
<v-text-field
v-model="displayPhoneNumber"
class="w-full p-2"
:label="t('phoneNumber')"
type="tel"
variant="outlined"
:error-messages="phoneErrors"
:rules="phoneRules"
validate-on="blur"
:placeholder="t('phonePlaceholder')"
@input="handlePhoneInput"
@keydown="handleKeydown"
maxlength="14"
/>
<v-alert
v-if="!!errorMessage"
outlined
type="error"
class="mt-4">
{{ errorMessage }}
</v-alert>
<div class="card dialog">
<div class="card-title">{{ t('changePhoneNumber') }}</div>
<div class="card-content">
<v-text-field
v-model="displayPhoneNumber"
:error-messages="phoneErrors"
:label="t('phoneNumber')"
:placeholder="t('phonePlaceholder')"
:rules="phoneRules"
class="w-full p-2"
maxlength="14"
type="tel"
validate-on="blur"
variant="outlined"
@input="handlePhoneInput"
@keydown="handleKeydown"
/>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="savePhoneNumber" :disabled="!canSave || isLoading">
{{ t('save') }}
</button>
</div>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button
class="secondary"
@click="$emit('closeRequested')"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isLoading"
class="primary"
@click="savePhoneNumber"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClient } from '@/plugins/api.js';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const { t } = useI18n();
const client = useClient();
const creatorProfileStore = useCreatorProfileStore();
const { t } = useI18n();
const client = useClient();
const creatorProfileStore = useCreatorProfileStore();
const props = defineProps({
creator: {
type: Object,
required: true
}
});
const props = defineProps({
creator: {
type: Object,
required: true,
},
});
// Format existing phone number to display format
const formatPhoneForDisplay = (phone) => {
if (!phone) return '';
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return phone;
};
// Format existing phone number to display format
const formatPhoneForDisplay = phone => {
if (!phone) return '';
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
return phone;
};
// Extract just the digits from formatted phone
const extractDigits = (formattedPhone) => {
return formattedPhone.replace(/\D/g, '');
};
// Extract just the digits from formatted phone
const extractDigits = formattedPhone => {
return formattedPhone.replace(/\D/g, '');
};
const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || ''));
const phoneDigits = ref(extractDigits(displayPhoneNumber.value));
const isLoading = ref(false);
const errorMessage = ref('');
const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || ''));
const phoneDigits = ref(extractDigits(displayPhoneNumber.value));
const isLoading = ref(false);
const errorMessage = ref('');
// Phone number formatting and validation
const formatPhoneNumber = (digits) => {
// Remove all non-digits
const cleaned = digits.replace(/\D/g, '');
// Apply formatting based on length
if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return `(${cleaned}`;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
};
// Phone number formatting and validation
const formatPhoneNumber = digits => {
// Remove all non-digits
const cleaned = digits.replace(/\D/g, '');
const handlePhoneInput = (event) => {
const input = event.target.value;
const digits = extractDigits(input);
// Limit to 10 digits
if (digits.length > 10) return;
phoneDigits.value = digits;
displayPhoneNumber.value = formatPhoneNumber(digits);
};
// Apply formatting based on length
if (cleaned.length === 0) return '';
if (cleaned.length <= 3) return `(${cleaned}`;
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
};
const handleKeydown = (event) => {
// Allow backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].includes(event.keyCode)) return;
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return;
// Allow arrow keys
if (event.keyCode >= 35 && event.keyCode <= 40) return;
// Only allow numbers (0-9)
if (event.keyCode < 48 || event.keyCode > 57) {
event.preventDefault();
}
};
const handlePhoneInput = event => {
const input = event.target.value;
const digits = extractDigits(input);
// Watch for changes to phoneDigits to update display
watch(phoneDigits, (newDigits) => {
displayPhoneNumber.value = formatPhoneNumber(newDigits);
});
// Limit to 10 digits
if (digits.length > 10) return;
const isValidPhoneNumber = (digits) => {
return digits.length === 10;
};
phoneDigits.value = digits;
displayPhoneNumber.value = formatPhoneNumber(digits);
};
const phoneRules = [
v => {
const digits = extractDigits(v);
return digits.length > 0 || t('validation.phoneRequired');
},
v => {
const digits = extractDigits(v);
return isValidPhoneNumber(digits) || t('validation.phoneInvalid');
},
];
const handleKeydown = event => {
// Allow backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].includes(event.keyCode)) return;
const phoneErrors = computed(() => {
if (phoneDigits.value.length === 0) {
return [t('validation.phoneRequired')];
}
if (!isValidPhoneNumber(phoneDigits.value)) {
return [t('validation.phoneInvalid')];
}
return [];
});
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return;
const canSave = computed(() => {
return phoneDigits.value.length === 10 &&
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '');
});
// Allow arrow keys
if (event.keyCode >= 35 && event.keyCode <= 40) return;
async function savePhoneNumber() {
if (!props.creator.id) {
console.error("Creator ID is missing!");
return;
}
// Only allow numbers (0-9)
if (event.keyCode < 48 || event.keyCode > 57) {
event.preventDefault();
}
};
if (!canSave.value) {
return;
}
// Watch for changes to phoneDigits to update display
watch(phoneDigits, newDigits => {
displayPhoneNumber.value = formatPhoneNumber(newDigits);
});
try {
isLoading.value = true;
errorMessage.value = '';
const isValidPhoneNumber = digits => {
return digits.length === 10;
};
// Save the formatted phone number
const formattedPhone = formatPhoneNumber(phoneDigits.value);
await client.post(
`/api/creators/${props.creator.id}/phone`,
{
phoneNumber: formattedPhone
}
);
const phoneRules = [
v => {
const digits = extractDigits(v);
return digits.length > 0 || t('validation.phoneRequired');
},
v => {
const digits = extractDigits(v);
return isValidPhoneNumber(digits) || t('validation.phoneInvalid');
},
];
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
// Close dialog
emit('closeRequested');
} catch (error) {
console.error("Error saving phone number:", error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
const phoneErrors = computed(() => {
if (phoneDigits.value.length === 0) {
return [t('validation.phoneRequired')];
}
if (!isValidPhoneNumber(phoneDigits.value)) {
return [t('validation.phoneInvalid')];
}
return [];
});
const canSave = computed(() => {
return (
phoneDigits.value.length === 10 &&
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '')
);
});
async function savePhoneNumber() {
if (!props.creator.id) {
console.error('Creator ID is missing!');
return;
}
if (!canSave.value) {
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
// Save the formatted phone number
const formattedPhone = formatPhoneNumber(phoneDigits.value);
await client.post(`/api/creators/${props.creator.id}/phone`, {
phoneNumber: formattedPhone,
});
// Refresh creator profile
await creatorProfileStore.fetchCreatorProfile();
// Close dialog
emit('closeRequested');
} catch (error) {
console.error('Error saving phone number:', error);
if (error?.response?.data?.errors) {
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
} else {
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
}
} finally {
isLoading.value = false;
}
}
} finally {
isLoading.value = false;
}
}
const emit = defineEmits(['closeRequested']);
const emit = defineEmits(['closeRequested']);
</script>
<style scoped>
.dialog {
@apply max-w-md mx-auto;
}
.dialog {
@apply max-w-md mx-auto;
}
</style>
<i18n>
{
"en": {
"changePhoneNumber": "Change Phone Number",
"phoneNumber": "Phone Number",
"phonePlaceholder": "(555) 123-4567",
"save": "Save",
"cancel": "Cancel",
"validation": {
"phoneRequired": "Phone number is required",
"phoneInvalid": "Please enter a complete 10-digit phone number"
"en": {
"changePhoneNumber": "Change Phone Number",
"phoneNumber": "Phone Number",
"phonePlaceholder": "(555) 123-4567",
"save": "Save",
"cancel": "Cancel",
"validation": {
"phoneRequired": "Phone number is required",
"phoneInvalid": "Please enter a complete 10-digit phone number"
},
"errors": {
"unexpected": "An unexpected error occurred"
}
},
"errors": {
"unexpected": "An unexpected error occurred"
"fr": {
"changePhoneNumber": "Modifier le numéro de téléphone",
"phoneNumber": "Numéro de téléphone",
"phonePlaceholder": "(555) 123-4567",
"save": "Enregistrer",
"cancel": "Annuler",
"validation": {
"phoneRequired": "Le numéro de téléphone est requis",
"phoneInvalid": "Veuillez entrer un numéro de téléphone complet à 10 chiffres"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
}
},
"fr": {
"changePhoneNumber": "Modifier le numéro de téléphone",
"phoneNumber": "Numéro de téléphone",
"phonePlaceholder": "(555) 123-4567",
"save": "Enregistrer",
"cancel": "Annuler",
"validation": {
"phoneRequired": "Le numéro de téléphone est requis",
"phoneInvalid": "Veuillez entrer un numéro de téléphone complet à 10 chiffres"
},
"errors": {
"unexpected": "Une erreur inattendue s'est produite"
}
},
"es": {
"changePhoneNumber": "Cambiar número de teléfono",
"phoneNumber": "Número de teléfono",
"phonePlaceholder": "(555) 123-4567",
"save": "Guardar",
"cancel": "Cancelar",
"validation": {
"phoneRequired": "El número de teléfono es obligatorio",
"phoneInvalid": "Por favor ingrese un número de teléfono completo de 10 dígitos"
},
"errors": {
"unexpected": "Se produjo un error inesperado"
}
}
}
</i18n>

View File

@@ -1,119 +1,120 @@
<script setup>
import {computed, ref, watch} from 'vue';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js';
import {useClient} from "@/plugins/api.js";
import NameEditor from "@/views/creators/NameEditor.vue";
import {useI18n} from 'vue-i18n';
import { computed, ref, watch } from 'vue';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
import { useClient } from '@/plugins/api.js';
import NameEditor from '@/views/creators/NameEditor.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
});
const emit = defineEmits(['closeRequested']);
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const {t} = useI18n();
const newSlug = ref(props.creator.slug);
const slugReservationId = ref(undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, (newValue) => {
isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) {
slugReservationId.value = undefined;
}
});
const canSave = computed(() => slugReservationId.value !== undefined && !isCurrentHandle.value);
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
const props = defineProps({
creator: {
required: true,
},
});
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 emit = defineEmits(['closeRequested']);
const cancel = () => {
emit('closeRequested');
};
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const { t } = useI18n();
const newSlug = ref(props.creator.slug);
const slugReservationId = ref(undefined);
const isOperationPending = ref(false);
const errorMessage = ref('');
const isCurrentHandle = ref(false);
// Watch for changes to the new slug to check if it's the same as the current one
watch(newSlug, newValue => {
isCurrentHandle.value = newValue === props.creator.slug;
if (isCurrentHandle.value) {
slugReservationId.value = undefined;
}
});
const canSave = computed(() => slugReservationId.value !== undefined && !isCurrentHandle.value);
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">
{{ t('title') }}
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
:original-slug="creator.slug"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
></name-editor>
<v-alert
v-if="!!errorMessage"
class="mt-4"
outlined
type="error"
>
{{ errorMessage }}
</v-alert>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
:disabled="!canSave || isOperationPending"
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
<div class="card-content">
<name-editor
v-model:name="newSlug"
:creator-name-reservation-id="slugReservationId"
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
:original-slug="creator.slug"
></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">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save"
:disabled="!canSave || isOperationPending">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
"en": {
"title": "Change Creator Handle"
},
"fr": {
"title": "Modifier l'identifiant du créateur"
},
"es": {
"title": "Cambiar identificador del creador"
}
"en": {
"title": "Change Creator Handle"
},
"fr": {
"title": "Modifier l'identifiant du créateur"
}
}
</i18n>
</i18n>

View File

@@ -1,86 +1,84 @@
<script setup>
import {useClient} from '@/plugins/api.js';
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js';
import { useClient } from '@/plugins/api.js';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested']);
const stripeId = ref('');
const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const save = async () => {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: stripeId.value,
const props = defineProps({
creator: {
required: true,
},
});
await creatorProfileStore.fetchCreatorProfile();
emits('closeRequested');
} catch (error) {
console.error('Error saving stripe id:', error);
}
};
const emits = defineEmits(['closeRequested']);
const cancel = () => {
emits('closeRequested');
};
const stripeId = ref('');
const { t } = useI18n();
const creatorProfileStore = useCreatorProfileStore();
const client = useClient();
const save = async () => {
try {
await client.post(`/api/membership/stripe-account`, {
stripeAccountId: stripeId.value,
});
await creatorProfileStore.fetchCreatorProfile();
emits('closeRequested');
} catch (error) {
console.error('Error saving stripe id:', error);
}
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
v-model="stripeId"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-content">
<v-text-field
v-model="stripeId"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
"en": {
"title": "Change Stripe ID",
"label": "Your Stripe ID"
},
"fr": {
"title": "Modifier l'ID Stripe",
"label": "Votre ID Stripe"
},
"es": {
"title": "Cambiar ID de Stripe",
"label": "Tu ID de Stripe"
}
"en": {
"title": "Change Stripe ID",
"label": "Your Stripe ID"
},
"fr": {
"title": "Modifier l'ID Stripe",
"label": "Votre ID Stripe"
}
}
</i18n>

View File

@@ -1,88 +1,82 @@
<script setup>
import {ref} from 'vue';
import {useClient} from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
const props = defineProps({
creator: {
required: true
}
});
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested']);
const emits = defineEmits(['closeRequested']);
const title = ref(props.creator.title);
const { t } = useI18n();
const title = ref(props.creator.title);
const { t } = useI18n();
const client = useClient();
const client = useClient();
async function save() {
try {
await client.post(
`/api/creators/${props.creator.id}/title`,
{
title: title.value
async function save() {
try {
await client.post(`/api/creators/${props.creator.id}/title`, {
title: title.value,
});
props.creator.title = title.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
}
);
}
props.creator.title = title.value;
emits('closeRequested');
} catch (error) {
console.error('Error saving title:', error);
}
}
const cancel = () => {
emits('closeRequested');
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-title">
{{ t('title') }}
<div class="card-content">
<v-text-field
v-model="title"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
<div class="card-content">
<v-text-field
v-model="title"
:label="t('label')"
outlined
variant="outlined"
></v-text-field>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
<i18n>
{
"en": {
"title": "Change Title",
"label": "Your title"
},
"fr": {
"title": "Modifier le titre",
"label": "Votre titre"
},
"es": {
"title": "Cambiar título",
"label": "Tu título"
}
"en": {
"title": "Change Title",
"label": "Your title"
},
"fr": {
"title": "Modifier le titre",
"label": "Votre titre"
}
}
</i18n>

View File

@@ -1,210 +1,200 @@
<script setup>
import {ref} from 'vue'
import {useClient} from "@/plugins/api.js";
import X from "@/views/svg/X.vue";
import Tiktok from "@/views/svg/Tiktok.vue";
import Reddit from "@/views/svg/Reddit.vue";
import Web from "@/views/svg/Web.vue";
import Youtube from "@/views/svg/Youtube.vue";
import Linkedin from "@/views/svg/Linkedin.vue";
import Instagram from "@/views/svg/Instagram.vue";
import Facebook from "@/views/svg/Facebook.vue";
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import X from '@/views/svg/X.vue';
import Tiktok from '@/views/svg/Tiktok.vue';
import Reddit from '@/views/svg/Reddit.vue';
import Web from '@/views/svg/Web.vue';
import Youtube from '@/views/svg/Youtube.vue';
import Linkedin from '@/views/svg/Linkedin.vue';
import Instagram from '@/views/svg/Instagram.vue';
import Facebook from '@/views/svg/Facebook.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
creator: {
required: true
}
})
const { t } = useI18n();
const props = defineProps({
creator: {
required: true,
},
});
const emits = defineEmits(['closeRequested'])
const emits = defineEmits(['closeRequested']);
const facebookUrl = ref(props.creator.socials.facebookUrl)
const instagramUrl = ref(props.creator.socials.instagramUrl)
const linkedInUrl = ref(props.creator.socials.linkedInUrl)
const redditUrl = ref(props.creator.socials.redditUrl)
const tikTokUrl = ref(props.creator.socials.tikTokUrl)
const websiteUrl = ref(props.creator.socials.websiteUrl)
const xUrl = ref(props.creator.socials.xUrl)
const youtubeUrl = ref(props.creator.socials.youtubeUrl)
const facebookUrl = ref(props.creator.socials.facebookUrl);
const instagramUrl = ref(props.creator.socials.instagramUrl);
const linkedInUrl = ref(props.creator.socials.linkedInUrl);
const redditUrl = ref(props.creator.socials.redditUrl);
const tikTokUrl = ref(props.creator.socials.tikTokUrl);
const websiteUrl = ref(props.creator.socials.websiteUrl);
const xUrl = ref(props.creator.socials.xUrl);
const youtubeUrl = ref(props.creator.socials.youtubeUrl);
const client = useClient()
const save = async () => {
try {
await client.post(
`/api/creators/${props.creator.id}/socials`,
{
"facebookUrl": facebookUrl.value || null,
"instagramUrl": instagramUrl.value || null,
"linkedInUrl": linkedInUrl.value || null,
"redditUrl": redditUrl.value || null,
"tikTokUrl": tikTokUrl.value || null,
"websiteUrl": websiteUrl.value || null,
"xUrl": xUrl.value || null,
"youtubeUrl": youtubeUrl.value || null,
})
const client = useClient();
const save = async () => {
try {
await client.post(`/api/creators/${props.creator.id}/socials`, {
facebookUrl: facebookUrl.value || null,
instagramUrl: instagramUrl.value || null,
linkedInUrl: linkedInUrl.value || null,
redditUrl: redditUrl.value || null,
tikTokUrl: tikTokUrl.value || null,
websiteUrl: websiteUrl.value || null,
xUrl: xUrl.value || null,
youtubeUrl: youtubeUrl.value || null,
});
props.creator.socials.facebookUrl = facebookUrl
props.creator.socials.instagramUrl = instagramUrl
props.creator.socials.linkedInUrl = linkedInUrl
props.creator.socials.redditUrl = redditUrl
props.creator.socials.tikTokUrl = tikTokUrl
props.creator.socials.websiteUrl = websiteUrl
props.creator.socials.xUrl = xUrl
props.creator.socials.youtubeUrl = youtubeUrl
props.creator.socials.facebookUrl = facebookUrl;
props.creator.socials.instagramUrl = instagramUrl;
props.creator.socials.linkedInUrl = linkedInUrl;
props.creator.socials.redditUrl = redditUrl;
props.creator.socials.tikTokUrl = tikTokUrl;
props.creator.socials.websiteUrl = websiteUrl;
props.creator.socials.xUrl = xUrl;
props.creator.socials.youtubeUrl = youtubeUrl;
emits('closeRequested')
} catch (error) {
console.error(error)
}
}
const cancel = () => {
emits('closeRequested')
}
emits('closeRequested');
} catch (error) {
console.error(error);
}
};
const cancel = () => {
emits('closeRequested');
};
</script>
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card dialog">
<div class="card-content">
<div class="editor-line">
<facebook class="social-icon"></facebook>
<input
v-model="facebookUrl"
:placeholder="t('facebook')"
class="input-field"
type="text"
/>
</div>
<div class="card-title">
{{ t('title') }}
<div class="editor-line">
<instagram class="social-icon"></instagram>
<input
v-model="instagramUrl"
:placeholder="t('instagram')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<linkedin class="social-icon"></linkedin>
<input
v-model="linkedInUrl"
:placeholder="t('linkedin')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<reddit class="social-icon"></reddit>
<input
v-model="redditUrl"
:placeholder="t('reddit')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<tiktok class="social-icon"></tiktok>
<input
v-model="tikTokUrl"
:placeholder="t('tiktok')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<web class="social-icon"></web>
<input
v-model="websiteUrl"
:placeholder="t('website')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<x class="social-icon"></x>
<input
v-model="xUrl"
:placeholder="t('x')"
class="input-field"
type="text"
/>
</div>
<div class="editor-line">
<youtube class="social-icon"></youtube>
<input
v-model="youtubeUrl"
:placeholder="t('youtube')"
class="input-field"
type="text"
/>
</div>
</div>
<div class="card-actions">
<button
class="secondary"
@click="cancel"
>
{{ t('cancel') }}
</button>
<button
class="primary"
@click="save"
>
{{ t('save') }}
</button>
</div>
</div>
<div class="card-content">
<div class="editor-line">
<facebook class="social-icon"></facebook>
<input
v-model="facebookUrl"
class="input-field"
:placeholder="t('facebook')"
type="text"
/>
</div>
<div class="editor-line">
<instagram class="social-icon"></instagram>
<input
v-model="instagramUrl"
class="input-field"
:placeholder="t('instagram')"
type="text"
/>
</div>
<div class="editor-line">
<linkedin class="social-icon"></linkedin>
<input
v-model="linkedInUrl"
class="input-field"
:placeholder="t('linkedin')"
type="text"
/>
</div>
<div class="editor-line">
<reddit class="social-icon"></reddit>
<input
v-model="redditUrl"
class="input-field"
:placeholder="t('reddit')"
type="text"
/>
</div>
<div class="editor-line">
<tiktok class="social-icon"></tiktok>
<input
v-model="tikTokUrl"
class="input-field"
:placeholder="t('tiktok')"
type="text"
/>
</div>
<div class="editor-line">
<web class="social-icon"></web>
<input
v-model="websiteUrl"
class="input-field"
:placeholder="t('website')"
type="text"
/>
</div>
<div class="editor-line">
<x class="social-icon"></x>
<input
v-model="xUrl"
class="input-field"
:placeholder="t('x')"
type="text"
/>
</div>
<div class="editor-line">
<youtube class="social-icon"></youtube>
<input
v-model="youtubeUrl"
class="input-field"
:placeholder="t('youtube')"
type="text"
/>
</div>
</div>
<div class="card-actions">
<button class="secondary"
@click="cancel">
{{ t('cancel') }}
</button>
<button class="primary"
@click="save">
{{ t('save') }}
</button>
</div>
</div>
</template>
<style scoped>
.editor-line {
@apply flex flex-row gap-4;
@apply items-center;
}
.editor-line {
@apply flex flex-row gap-4;
@apply items-center;
}
.social-icon {
@apply w-8 h-8;
}
.input-field {
@apply w-full p-[10px];
@apply rounded-sm;
@apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70]
}
.social-icon {
@apply w-8 h-8;
}
.input-field {
@apply w-full p-[10px];
@apply rounded-sm;
@apply transition duration-200;
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
@apply hover:ring-hutopyPrimary;
@apply placeholder:text-[#6D6C70];
}
</style>
<i18n>
{
"en": {
"title": "Social Media Links"
},
"fr": {
"title": "Liens des réseaux sociaux"
},
"es": {
"title": "Enlaces de redes sociales"
}
"en": {
"title": "Social Media Links"
},
"fr": {
"title": "Liens des réseaux sociaux"
}
}
</i18n>