Compare commits

...

10 Commits

Author SHA1 Message Date
35c7530ec7 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
2025-12-08 16:11:19 -05:00
a0c8571fca Merge branch 'backup' 2025-12-08 16:06:28 -05:00
6211339c0e pending 2025-12-08 15:57:32 -05:00
d4c278be48 feat(frontend): change some descriptions 2025-12-08 15:57:25 -05:00
3a84defec5 chore(debug): fix windows/linux case-sensitive issues with the database name 2025-12-08 15:57:00 -05:00
41da496870 feat(frontend): remove creators guide 2025-12-08 15:56:50 -05:00
1e46f5bbaa chore(codebase): remove dead code 2025-08-26 16:56:17 -04:00
9da862b629 refactor(ui): simplify layout by introducing an unified site-bar 2025-08-26 16:36:10 -04:00
262f21d157 feat(i18n): deprecate Spanish language support in the frontend 2025-08-26 15:58:06 -04:00
fbf7963842 feat(frontend): change some descriptions 2025-08-13 13:38:48 -04:00
49 changed files with 4245 additions and 4508 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

@@ -3,10 +3,10 @@
<h1>À propos</h1>
<p>
Bienvenue sur la page "À Propos" dHutopy, nous partageons notre histoire, notre mission,
notre vision, et vous psentons l'équipe passionnée qui rend tout cela possible. Hutopy
n'est pas seulement une plateforme ; c'est une communauté, un mouvement, un lieu où la
créativité rencontre la technologie pour créer des expériences inoubliables.
Notre mission chez Hutopy est de développer des outils permettant à chaque utilisateur de se démarquer, autant dans
le monde réel que dans le monde numérique, en cant un véritable pont entre les deux. Que vous soyez artiste de
rue, organisme à but non lucratif ou toute autre personne ayant besoin doutils pour être soutenue, Hutopy est
pour vous.
</p>
<h2>Notre Histoire</h2>
@@ -51,26 +51,6 @@
<div class="members">
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
alt="Marc-Olivier Hébert">
</div>
<div class="card-body">
<div class="member-name">Marc-Olivier Hébert</div>
<div class="member-title">CEO / Fondateur</div>
<div class="member-description">
<p>
Avec une vision claire et un engagement sans faille, il a lancé Hutopy pour changer la manière dont le
contenu est créé et partagé.
</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
@@ -81,12 +61,31 @@
<div class="card-body">
<div class="member-name">Pascal Marchesseault</div>
<div class="member-title">Gestionnaire de projet / UI</div>
<div class="member-title">Président-directeur général</div>
<div class="member-description">
<p>
A pour mission d'assurer le développement du projet tout en cant une interface
qui permettra au projet d'avoir une interaction positive et enrichissante avec Hutopy pour les
utilisateurs.
Avec une vision claire et un engagement sans faille, il a toujours été psent pour veiller à la bonne
réalisation du projet.
</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<img class="member-profile-picture"
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
alt="Marc-Olivier Hébert">
</div>
<div class="card-body">
<div class="member-name">Marc-Olivier Hébert</div>
<div class="member-title">Directeur de linnovation et de la vision</div>
<div class="member-description">
<p>
Avec une vision avant-gardiste, il permet à léquipe dexplorer de nouvelles idées ou de les réinventer.
</p>
</div>
</div>
@@ -103,7 +102,7 @@
<div class="card-body">
<div class="member-name">Chloé Beaugrand</div>
<div class="member-title">Responsable Marketing</div>
<div class="member-title">Directrice marketing</div>
<div class="member-description">
<p>
Elle façonne l'image dHutopy et engage notre communauté à travers des campagnes
@@ -124,7 +123,7 @@
<div class="card-body">
<div class="member-name">Jonathan Bourdon</div>
<div class="member-title">Directeur Technique</div>
<div class="member-title">Directeur des technologies</div>
<div class="member-description">
<p>
Son expérience d'architecte senior nous permet de développer un logiciel avec
@@ -190,4 +189,6 @@
@apply font-sans text-lg text-justify;
}
</style>
</style>
<script setup lang="ts">
</script>

View File

@@ -1,8 +1,6 @@
<template>
<h1>FAQ</h1>
<h2>Foire Aux Questions</h2>
<h1>Foire Aux Questions</h1>
<p>
La section FAQ de Hutopy est votre ressource essentielle pour trouver des réponses rapides aux questions les plus
@@ -11,7 +9,7 @@
dernières fonctionnalités.
</p>
<h2>Comment puis-je créer un compte sur Hutopy ?</h2>
<h2>Comment puis-je créer un compte sur Hutopy?</h2>
<p>
Créer un compte est simple! Visitez notre page d'inscription, remplissez les informations requises, et suivez les
@@ -19,43 +17,35 @@
commencer à explorer et à interagir avec la communauté Hutopy immédiatement après.
</p>
<h2>Quels types de contenu puis-je publier sur Hutopy ?</h2>
<p>
Hutopy accueille une large variété de contenus créatifs, incluant mais non limité à des vidéos, articles,
podcasts, et illustrations. Nous encourageons la diversité et l'originalité, tant que le contenu respecte nos
valeurs.
</p>
<h2>Comment Hutopy rémunère-t-il les créateurs de contenu ?</h2>
<p>
Les créateurs peuvent monétiser leur contenu de plusieurs façons, notamment via des abonnements payants et des
Les créateurs peuvent monétiser leur contenu de plusieurs façons des
dons de la part des utilisateurs.
</p>
<h2>Comment puis-je modifier mon profil ?</h2>
<h2>Comment puis-je modifier mon profil?</h2>
<p>
Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos
informations, ajouter une bio, changer votre photo de profil, et plus encore.
</p>
<h2>Est-il possible de supprimer mon compte ?</h2>
<h2>Est-il possible de supprimer mon compte?</h2>
<p>
Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette
action est irréversible.
</p>
<h2>Que faire si j'oublie mon mot de passe ?</h2>
<h2>Que faire si j'oublie mon mot de passe?</h2>
<p>
Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre
mot de passe via votre adresse courriel.
</p>
<h2>Comment signaler un contenu inapproprié ?</h2>
<h2>Comment signaler un contenu inapproprié?</h2>
<p>
Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la
@@ -63,7 +53,7 @@
modération.
</p>
<h2>Comment puis-je contacter le support Hutopy ?</h2>
<h2>Comment puis-je contacter le support Hutopy?</h2>
<p>
Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à
@@ -71,7 +61,7 @@
demandes.
</p>
<h2>Quels sont les frais pour les créateurs sur Hutopy ?</h2>
<h2>Quels sont les frais pour les créateurs sur Hutopy?</h2>
<p>
Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour
@@ -80,38 +70,15 @@
Stripe et le développement continu pour améliorer votre expérience sur Hutopy.
</p>
<h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy ?</h2>
<h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy?</h2>
<p>
Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
</p>
<h2>Les utilisateurs doivent-ils payer pour accéder au contenu sur Hutopy ?</h2>
<p>
Hutopy offre à la fois du contenu gratuit et du contenu premium. Les utilisateurs peuvent accéder gratuitement à
une partie du contenu sur la plateforme. Cependant, certains créateurs peuvent choisir de rendre leur contenu
accessible uniquement via un abonnement payant ou un achat unique pour soutenir leur travail.
</p>
<h2>Existe-t-il des frais pour retirer mes gains de la plateforme ?</h2>
<p>
Les créateurs peuvent retirer leurs gains sans frais supplémentaires de la part dHutopy. Cependant, les
transactions bancaires ou les transferts vers des portefeuilles électroniques peuvent être soumis aux frais
standards imposés par ces services ou institutions financières, mais pas par Hutopy.
</p>
<h2>Les frais Hutopy sont-ils les mêmes pour tous les types de contenu ?</h2>
<p>
Oui, les frais de commission dHutopy sont uniformément appliqués à tous les types de contenu et de transactions
sur la plateforme pour maintenir la simplicité et la transparence et ce peu importe le montant.
</p>
</template>
<style scoped>
@import '@/views/documentation/documentation.css';
</style>
</style>

View File

@@ -9,11 +9,11 @@
aussi enrichissante et agréable que possible.
</p>
<h2>FAQ (Foire Aux Questions)</h2>
<h2>Foire Aux Questions</h2>
<p>
Retrouvez les réponses aux questions les plus fréquemment posées concernant l'utilisation dHutopy, les
fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la FAQ
fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la <a href="FAQ" style="color: #a30e79;">FAQ</a>
</p>
<h2>Contactez-Nous</h2>

View File

@@ -2,11 +2,11 @@
<h1>Frais</h1>
<p>
Découvrez Hutopy, l'endroit où la valorisation de votre travail atteint son apogée. Avec une commission réduite à
seulement 9 %, notre engagement envers votre succès est palpable. Chaque pourcentage prélevé est réinvesti avec soin
pour catalyser votre croissance afin de développer des fonctionnalités innovantes, maintenir une infrastructure
technologique de pointe, et un support utilisateur de premier ordre. Notre objectif ? Amplifier votre expansion et
garantir une expérience utilisateur sans précédent.
Hutopy ne prend que 9,1 % de commission sur vos transactions notre unique façon de soutenir la plateforme.
Chaque dollar prélevé est intégralement réinvesti pour développer des fonctionnalités innovantes, maintenir une
infrastructure technologique de pointe et offrir un support utilisateur irréprochable. Ce modèle nous permet
dapporter, dans un avenir très proche, des outils uniques qui vous aideront à vous démarquer encore davantage
et à vivre pleinement de votre passion.
</p>
<p>

View File

@@ -14,7 +14,7 @@ h2 {
p {
@apply text-hOnBackground;
@apply font-sans font-normal text-base;
@apply font-sans font-normal text-lg;
@apply tracking-normal;
@apply mb-6;
@apply text-justify;

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,297 +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('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 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",
"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."
},
"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>