refactor: organize frontend by feature
This commit is contained in:
@@ -1 +0,0 @@
|
||||
|
||||
74
frontend/src/features/auth/composables/useFacebookLogin.js
Normal file
74
frontend/src/features/auth/composables/useFacebookLogin.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useHead} from "@vueuse/head";
|
||||
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
||||
import config from "@/config.js";
|
||||
|
||||
export function useFacebookLogin() {
|
||||
const isSdkLoaded = ref(false);
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
src: "https://connect.facebook.net/en_US/sdk.js",
|
||||
async: true,
|
||||
defer: true,
|
||||
onload: "initializeFacebookSDK()",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
(function (d, s, id) {
|
||||
var js, fjs = d.getElementsByTagName(s)[0];
|
||||
if (d.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = "https://connect.facebook.net/en_US/sdk.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}(document, 'script', 'facebook-jssdk'));
|
||||
|
||||
const initializeFacebookSDK = () => {
|
||||
window.fbAsyncInit = function () {
|
||||
FB.init({
|
||||
appId: config.facebookAppId,
|
||||
xfbml: true,
|
||||
version: 'v22.0'
|
||||
});
|
||||
FB.AppEvents.logPageView();
|
||||
|
||||
isSdkLoaded.value = true;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const loginWithFacebook = () => {
|
||||
if (!isSdkLoaded.value) {
|
||||
console.error("Facebook SDK non encore chargé !");
|
||||
return;
|
||||
}
|
||||
|
||||
window.FB.login(
|
||||
(response) => {
|
||||
if (response.authResponse) {
|
||||
console.log("Utilisateur connecté :", response);
|
||||
const authStore = useAuthStore();
|
||||
authStore.loginWithFacebook(response.authResponse);
|
||||
} else {
|
||||
console.log("Connexion annulée ou échouée.");
|
||||
}
|
||||
},
|
||||
{
|
||||
scope: "public_profile,email"
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFacebookSDK();
|
||||
});
|
||||
|
||||
return {
|
||||
loginWithFacebook,
|
||||
};
|
||||
}
|
||||
300
frontend/src/features/auth/stores/authStore.js
Normal file
300
frontend/src/features/auth/stores/authStore.js
Normal file
@@ -0,0 +1,300 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { formatDuration } from '@/internal_time_ago.js';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const clientApi = useClient();
|
||||
const router = useRouter();
|
||||
|
||||
const isRefreshing = ref(false);
|
||||
let refreshPromise = null;
|
||||
|
||||
const accessToken = useSessionStorage('auth-accessToken', undefined);
|
||||
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
|
||||
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
|
||||
serializer: {
|
||||
read: v => (v ? JSON.parse(v) : null),
|
||||
write: v => (v ? JSON.stringify(v) : null),
|
||||
},
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
const userId = computed(() => tokenClaims.value?.sub);
|
||||
const userRoles = computed(() => {
|
||||
const claims = tokenClaims.value ?? {};
|
||||
const candidates = [
|
||||
claims.role,
|
||||
claims.roles,
|
||||
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
|
||||
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
|
||||
|
||||
return [...new Set(candidates)];
|
||||
});
|
||||
const persona = computed(() => tokenClaims.value?.persona ?? null);
|
||||
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
|
||||
const isClient = computed(() => userRoles.value.includes('Client'));
|
||||
const isProvider = computed(() => userRoles.value.includes('Provider'));
|
||||
|
||||
function updateTokens(data) {
|
||||
if (!data?.accessToken || !data?.refreshToken) {
|
||||
throw new Error('Invalid token data');
|
||||
}
|
||||
accessToken.value = data.accessToken;
|
||||
refreshToken.value = data.refreshToken;
|
||||
const claims = getClaimsFromToken(data.accessToken);
|
||||
tokenClaims.value = claims;
|
||||
console.log('Tokens updated, user ID:', claims?.sub);
|
||||
}
|
||||
|
||||
function cleanTokens() {
|
||||
console.log('cleanTokens called - clearing stored tokens');
|
||||
accessToken.value = undefined;
|
||||
refreshToken.value = undefined;
|
||||
tokenClaims.value = null;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
cleanTokens();
|
||||
await router.push('/');
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
console.log('login called with email:', email);
|
||||
if (!email || !password) {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await clientApi.post('api/users/login', {
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (!response.data?.accessToken || !response.data?.refreshToken) {
|
||||
throw new Error('Invalid login response');
|
||||
}
|
||||
|
||||
updateTokens(response.data);
|
||||
console.log('login successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
cleanTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithGoogle(accessTokenParam) {
|
||||
console.log('loginWithGoogle called');
|
||||
if (!accessTokenParam) {
|
||||
throw new Error('Google access token is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await clientApi.post('api/users/login-with-google', {
|
||||
token: accessTokenParam,
|
||||
});
|
||||
|
||||
if (!response.data?.accessToken || !response.data?.refreshToken) {
|
||||
throw new Error('Invalid Google login response');
|
||||
}
|
||||
|
||||
updateTokens(response.data);
|
||||
console.log('Google login successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Google login failed:', error);
|
||||
cleanTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithFacebook(authResponse) {
|
||||
console.log('loginWithFacebook called');
|
||||
if (!authResponse?.accessToken) {
|
||||
throw new Error('Facebook access token is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await clientApi.post('api/users/login-with-facebook', {
|
||||
token: authResponse.accessToken,
|
||||
});
|
||||
|
||||
if (!response.data?.accessToken || !response.data?.refreshToken) {
|
||||
throw new Error('Invalid Facebook login response');
|
||||
}
|
||||
|
||||
updateTokens(response.data);
|
||||
console.log('Facebook login successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Facebook login failed:', error);
|
||||
cleanTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
console.log('refresh called');
|
||||
|
||||
if (!refreshToken.value) {
|
||||
cleanTokens(); // Clear tokens first
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
if (isRefreshing.value && refreshPromise) {
|
||||
console.log('Already refreshing, returning existing refreshPromise');
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
try {
|
||||
isRefreshing.value = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
console.log('Sending refresh request...');
|
||||
|
||||
const response = await clientApi.post('api/users/refresh', {
|
||||
refreshToken: refreshToken.value,
|
||||
});
|
||||
|
||||
if (!response.data?.accessToken || !response.data?.refreshToken) {
|
||||
throw new Error('Invalid refresh response');
|
||||
}
|
||||
|
||||
updateTokens({
|
||||
accessToken: response.data.accessToken,
|
||||
refreshToken: response.data.refreshToken,
|
||||
});
|
||||
|
||||
console.log('Token refresh successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
cleanTokens();
|
||||
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const returnUrl = currentRoute.fullPath;
|
||||
|
||||
// Handle navigation
|
||||
router
|
||||
.push({
|
||||
name: 'login',
|
||||
query: { returnUrl },
|
||||
})
|
||||
.catch(navError => {
|
||||
console.error('Navigation error after token refresh failure:', navError);
|
||||
});
|
||||
|
||||
throw error; // Re-throw to notify callers
|
||||
}
|
||||
})();
|
||||
|
||||
return await refreshPromise;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure these are always reset, even if an error is thrown
|
||||
isRefreshing.value = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getClaimsFromToken(token) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
return jwtDecode(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to decode token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenExpiringSoon(token) {
|
||||
if (!token) {
|
||||
console.log('No token provided, considered expiring soon');
|
||||
return true;
|
||||
}
|
||||
|
||||
const claims = getClaimsFromToken(token);
|
||||
if (!claims || !claims.exp) {
|
||||
console.log('No valid claims found, considered expiring soon');
|
||||
return true;
|
||||
}
|
||||
|
||||
const expirationTime = claims.exp * 1000; // Convert to milliseconds
|
||||
const currentTime = Date.now();
|
||||
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
|
||||
|
||||
// Calculate time remaining (can be negative if already expired)
|
||||
const timeRemainingMs = expirationTime - currentTime;
|
||||
|
||||
// Token is expiring soon if less than 2 minutes remaining or already expired
|
||||
const isExpiring = timeRemainingMs < fiveMinutesInMs;
|
||||
|
||||
// Determine the sign for display purposes
|
||||
const formattedTimeRemaining =
|
||||
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
|
||||
|
||||
if (isExpiring) {
|
||||
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
|
||||
expirationTime: new Date(expirationTime).toLocaleString(),
|
||||
currentTime: new Date(currentTime).toLocaleString(),
|
||||
timeRemaining: formattedTimeRemaining,
|
||||
});
|
||||
}
|
||||
|
||||
return isExpiring;
|
||||
}
|
||||
|
||||
async function changePassword(newPassword) {
|
||||
console.log('changePassword called');
|
||||
if (!isAuthenticated.value) {
|
||||
throw new Error('User must be authenticated to change password');
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new Error('New password is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await clientApi.post('api/users/set-password', {
|
||||
newPassword,
|
||||
});
|
||||
|
||||
console.log('Password changed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Password change failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyRole(roles) {
|
||||
return roles.some(role => userRoles.value.includes(role));
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
userId,
|
||||
userRoles,
|
||||
persona,
|
||||
hasAnyRole,
|
||||
isManager,
|
||||
isClient,
|
||||
isProvider,
|
||||
isRefreshing,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithFacebook,
|
||||
logout,
|
||||
refresh,
|
||||
isTokenExpiringSoon,
|
||||
changePassword,
|
||||
};
|
||||
});
|
||||
218
frontend/src/features/auth/views/ForgotPasswordView.vue
Normal file
218
frontend/src/features/auth/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +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>
|
||||
|
||||
<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
|
||||
class="form-label"
|
||||
for="email"
|
||||
>
|
||||
{{ t('email') }}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
class="form-input"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
class="text-sm text-blue-500"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</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>
|
||||
</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';
|
||||
|
||||
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('');
|
||||
|
||||
async function handleForgotPassword() {
|
||||
// Reset notification states
|
||||
showSuccessMessage.value = false;
|
||||
showErrorMessage.value = false;
|
||||
|
||||
if (!email.value) {
|
||||
errorMessage.value = t('emailRequired');
|
||||
showErrorMessage.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Call password reset API
|
||||
await clientApi.post('api/users/forgot-password', {
|
||||
email: email.value.trim(),
|
||||
});
|
||||
|
||||
// Show success message
|
||||
showSuccessMessage.value = true;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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];
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(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."
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
214
frontend/src/features/auth/views/LoginView.vue
Normal file
214
frontend/src/features/auth/views/LoginView.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
{{ t('signIn') }}
|
||||
</v-btn>
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="cursor-pointer text-sm text-blue-500"
|
||||
@click="forgotPassword"
|
||||
>
|
||||
{{ t('forgotPassword') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-center">
|
||||
<a
|
||||
class="cursor-pointer text-sm text-blue-500"
|
||||
@click="resendVerification"
|
||||
>
|
||||
{{ t('resendVerification') }}
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<!-- 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 '@/features/auth/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 email = ref('');
|
||||
const password = ref('');
|
||||
const errorSnackBar = ref(false);
|
||||
const showPassword = ref(false);
|
||||
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
errorSnackBar.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
}
|
||||
</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"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
237
frontend/src/features/auth/views/RegisterView.vue
Normal file
237
frontend/src/features/auth/views/RegisterView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="flex min-h-full w-full items-center justify-center p-20">
|
||||
<!-- Show verification message on success -->
|
||||
<div
|
||||
v-if="registrationSuccess"
|
||||
class="card justify-items-center"
|
||||
>
|
||||
<img
|
||||
:alt="t('alt')"
|
||||
src="/images/hutopymedia/loginpage/hutopylogin.svg"
|
||||
/>
|
||||
<div class="flex flex-col gap-10 text-center">
|
||||
<h1 class="login-text text-2xl font-bold text-green-600">
|
||||
{{ t('success.title') }}
|
||||
</h1>
|
||||
<div class="text-hOnSurface">
|
||||
<p>{{ t('success.message') }}</p>
|
||||
<p class="mt-2 font-medium">{{ userEmail }}</p>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<router-link
|
||||
class="text-blue-500 hover:underline"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('success.backToLogin') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ path: '/verify-email', query: { email: userEmail } }"
|
||||
class="text-blue-500 hover:underline"
|
||||
>
|
||||
{{ t('success.resendVerification') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show registration form -->
|
||||
<div
|
||||
v-else
|
||||
class="card justify-items-center"
|
||||
>
|
||||
<img
|
||||
:alt="t('alt')"
|
||||
src="/images/hutopymedia/loginpage/hutopylogin.svg"
|
||||
/>
|
||||
<div class="flex flex-col gap-10">
|
||||
<h1 class="login-text text-center text-2xl font-bold">
|
||||
{{ t('title') }}
|
||||
</h1>
|
||||
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<div class="flex flex-col gap-4">
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
:label="t('name')"
|
||||
required
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:label="t('email')"
|
||||
required
|
||||
type="email"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:hint="t('passwordRequirements')"
|
||||
: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>
|
||||
|
||||
<v-text-field
|
||||
v-model="confirmPassword"
|
||||
:label="t('confirmPassword')"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
required
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-icon
|
||||
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
|
||||
class="visibility-toggle"
|
||||
size="small"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn
|
||||
:loading="isLoading"
|
||||
block
|
||||
color="primary"
|
||||
type="submit"
|
||||
>
|
||||
{{ t('register') }}
|
||||
</v-btn>
|
||||
|
||||
<!-- Error message displayed as block text below submit button -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
{{ t('alreadyHaveAccount') }}
|
||||
<router-link
|
||||
class="text-blue-500"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</v-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const clientApi = useClient();
|
||||
|
||||
const name = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const registrationSuccess = ref(false);
|
||||
const userEmail = ref('');
|
||||
|
||||
async function handleRegister() {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('passwordsDoNotMatch');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
await clientApi.post('api/users/register', {
|
||||
name: name.value,
|
||||
email: email.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
// On success, show verification message
|
||||
userEmail.value = email.value.trim();
|
||||
registrationSuccess.value = true;
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
errorMessage.value = error.response?.data?.message || t('registrationFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Create your account",
|
||||
"alt": "Hutopy Registration",
|
||||
"name": "Full Name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordRequirements": "Password must be at least 8 characters",
|
||||
"register": "Register",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"signIn": "Sign in",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"registrationFailed": "Registration failed. Please try again.",
|
||||
"success": {
|
||||
"title": "Registration Successful!",
|
||||
"message": "Please check your email to verify your account. We've sent a verification link to:",
|
||||
"backToLogin": "Back to Login",
|
||||
"resendVerification": "Didn't receive the email? Resend verification"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"title": "Créer votre compte",
|
||||
"alt": "Inscription Hutopy",
|
||||
"name": "Nom complet",
|
||||
"email": "Email",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"register": "S'inscrire",
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte?",
|
||||
"signIn": "Se connecter",
|
||||
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
|
||||
"registrationFailed": "L'inscription a échoué. Veuillez réessayer.",
|
||||
"success": {
|
||||
"title": "Inscription réussie!",
|
||||
"message": "Veuillez vérifier votre email pour activer votre compte. Nous avons envoyé un lien de vérification à:",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
264
frontend/src/features/auth/views/ResetPasswordView.vue
Normal file
264
frontend/src/features/auth/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<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">
|
||||
<h1 class="text-center text-2xl font-bold">
|
||||
{{ t('title') }}
|
||||
</h1>
|
||||
|
||||
<form
|
||||
class="card"
|
||||
@submit.prevent="handleResetPassword"
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="form-field">
|
||||
<label
|
||||
class="form-label"
|
||||
for="password"
|
||||
>
|
||||
{{ t('newPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
class="password-toggle"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<v-icon
|
||||
:icon="showPassword ? mdiEyeOff : mdiEye"
|
||||
size="small"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ t('passwordRequirements') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label
|
||||
class="form-label"
|
||||
for="confirmPassword"
|
||||
>
|
||||
{{ t('confirmPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
class="password-toggle"
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<v-icon
|
||||
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
|
||||
size="small"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="error-message"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
class="primary w-full"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="loading-spinner mr-2"
|
||||
></span>
|
||||
{{ t('resetPassword') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Success message -->
|
||||
<div
|
||||
v-if="success"
|
||||
class="success-message"
|
||||
>
|
||||
{{ t('passwordResetSuccess') }}
|
||||
<div class="mt-4">
|
||||
<router-link
|
||||
class="text-blue-500"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('proceedToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const clientApi = useClient();
|
||||
|
||||
const email = ref('');
|
||||
const token = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const success = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
// Get email and token from URL query parameters
|
||||
email.value = route.query.email || '';
|
||||
token.value = route.query.token || '';
|
||||
|
||||
// Validate that we have both email and token
|
||||
if (!email.value || !token.value) {
|
||||
errorMessage.value = t('invalidResetLink');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleResetPassword() {
|
||||
// Reset error message
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate passwords match
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('passwordsDoNotMatch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.value.length < 8) {
|
||||
errorMessage.value = t('passwordTooShort');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that we have email and token
|
||||
if (!email.value || !token.value) {
|
||||
errorMessage.value = t('invalidResetLink');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Call password reset API
|
||||
await clientApi.post('api/users/reset-password', {
|
||||
email: email.value,
|
||||
token: token.value,
|
||||
newPassword: password.value,
|
||||
});
|
||||
|
||||
// Show success message
|
||||
success.value = true;
|
||||
|
||||
// Clear form fields
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
|
||||
// Redirect to login after a delay
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error('Password reset failed:', error);
|
||||
errorMessage.value = error.response?.data || t('resetFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
form {
|
||||
@apply bg-hSurface rounded-xl p-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-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;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-100 dark:bg-red-900 dark:text-red-300;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
@apply p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-100 dark:bg-green-900 dark:text-green-300 text-center;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
@apply absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-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];
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Reset Your Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordRequirements": "Password must be at least 8 characters",
|
||||
"resetPassword": "Reset Password",
|
||||
"passwordResetSuccess": "Your password has been reset successfully!",
|
||||
"proceedToLogin": "Proceed to Login",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 8 characters long",
|
||||
"resetFailed": "Password reset failed. Please try again or request a new reset link.",
|
||||
"invalidResetLink": "Invalid or expired reset link. Please request a new password reset."
|
||||
},
|
||||
"fr": {
|
||||
"title": "Réinitialiser Votre Mot de Passe",
|
||||
"newPassword": "Nouveau Mot de Passe",
|
||||
"confirmPassword": "Confirmer le Mot de Passe",
|
||||
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"resetPassword": "Réinitialiser le Mot de Passe",
|
||||
"passwordResetSuccess": "Votre mot de passe a été réinitialisé avec succès!",
|
||||
"proceedToLogin": "Procéder à la Connexion",
|
||||
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
237
frontend/src/features/auth/views/VerifyEmailView.vue
Normal file
237
frontend/src/features/auth/views/VerifyEmailView.vue
Normal file
@@ -0,0 +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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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();
|
||||
|
||||
// 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('');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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(),
|
||||
});
|
||||
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"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
122
frontend/src/features/channels/stores/channelsStore.js
Normal file
122
frontend/src/features/channels/stores/channelsStore.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
export const useChannelsStore = defineStore('channels', () => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
|
||||
serializer: {
|
||||
read: value => (value ? JSON.parse(value) : {}),
|
||||
write: value => JSON.stringify(value ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const channels = computed(() => {
|
||||
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
|
||||
|
||||
if (!currentWorkspaceId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const derivedChannels = new Map();
|
||||
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
|
||||
|
||||
for (const item of contentItemsStore.items) {
|
||||
for (const name of parseTargets(item.publicationTargets)) {
|
||||
const key = slugify(name);
|
||||
const existing = derivedChannels.get(key) ?? {
|
||||
id: key,
|
||||
name,
|
||||
network: null,
|
||||
source: 'derived',
|
||||
};
|
||||
|
||||
derivedChannels.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const channel of customChannels) {
|
||||
derivedChannels.set(channel.id, {
|
||||
...channel,
|
||||
source: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
});
|
||||
|
||||
const availableNetworks = [
|
||||
'Instagram',
|
||||
'TikTok',
|
||||
'Facebook',
|
||||
'LinkedIn',
|
||||
'YouTube',
|
||||
'X',
|
||||
'Reddit',
|
||||
'Website',
|
||||
];
|
||||
|
||||
function createChannel(payload) {
|
||||
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
|
||||
|
||||
if (!currentWorkspaceId) {
|
||||
throw new Error('An active workspace is required to create a channel.');
|
||||
}
|
||||
|
||||
const normalizedName = payload.name.trim();
|
||||
const normalizedNetwork = payload.network.trim();
|
||||
|
||||
if (!normalizedName) {
|
||||
throw new Error('Channel name is required.');
|
||||
}
|
||||
|
||||
if (!normalizedNetwork) {
|
||||
throw new Error('Network is required.');
|
||||
}
|
||||
|
||||
if (!availableNetworks.includes(normalizedNetwork)) {
|
||||
throw new Error('Selected network is invalid.');
|
||||
}
|
||||
|
||||
const existing = channels.value.some(channel =>
|
||||
channel.name.toLowerCase() === normalizedName.toLowerCase()
|
||||
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
throw new Error('A channel with this name already exists for the selected network.');
|
||||
}
|
||||
|
||||
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
|
||||
customChannelsByWorkspace.value = {
|
||||
...customChannelsByWorkspace.value,
|
||||
[currentWorkspaceId]: [
|
||||
...next,
|
||||
{
|
||||
id: slugify(`${normalizedNetwork}-${normalizedName}`),
|
||||
name: normalizedName,
|
||||
network: normalizedNetwork,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseTargets(value) {
|
||||
return (value ?? '')
|
||||
.split(/[,\n]+/)
|
||||
.map(target => target.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
availableNetworks,
|
||||
channels,
|
||||
createChannel,
|
||||
};
|
||||
});
|
||||
376
frontend/src/features/channels/views/ChannelsView.vue
Normal file
376
frontend/src/features/channels/views/ChannelsView.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import {
|
||||
mdiClose,
|
||||
mdiFacebook,
|
||||
mdiInstagram,
|
||||
mdiLinkedin,
|
||||
mdiMusicNote,
|
||||
mdiPlus,
|
||||
mdiReddit,
|
||||
mdiWeb,
|
||||
mdiYoutube,
|
||||
} from '@mdi/js';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const channelsStore = useChannelsStore();
|
||||
|
||||
const isCreateFormVisible = ref(false);
|
||||
const formError = ref(null);
|
||||
const activeNetwork = ref('Instagram');
|
||||
const form = reactive({
|
||||
name: '',
|
||||
network: 'Instagram',
|
||||
});
|
||||
|
||||
const networkOptions = [
|
||||
{ value: 'Instagram', icon: mdiInstagram },
|
||||
{ value: 'TikTok', icon: mdiMusicNote },
|
||||
{ value: 'Facebook', icon: mdiFacebook },
|
||||
{ value: 'LinkedIn', icon: mdiLinkedin },
|
||||
{ value: 'YouTube', icon: mdiYoutube },
|
||||
{ value: 'X', icon: mdiClose },
|
||||
{ value: 'Reddit', icon: mdiReddit },
|
||||
{ value: 'Website', icon: mdiWeb },
|
||||
];
|
||||
|
||||
const configuredChannels = computed(() =>
|
||||
channelsStore.channels
|
||||
.filter(channel => channel.network)
|
||||
.map(channel => {
|
||||
const metrics = buildMetrics(channel.name);
|
||||
|
||||
return {
|
||||
...channel,
|
||||
...metrics,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const channelsForActiveNetwork = computed(() =>
|
||||
configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
|
||||
);
|
||||
|
||||
function buildMetrics(channelName) {
|
||||
const matches = contentItemsStore.items.filter(item =>
|
||||
parseTargets(item.publicationTargets).some(target => target.toLowerCase() === channelName.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
scheduled: matches.length,
|
||||
nextDueDate: matches
|
||||
.filter(item => item.dueDate)
|
||||
.sort((left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime())[0]?.dueDate ?? null,
|
||||
readyCount: matches.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length,
|
||||
blockedCount: matches.filter(item =>
|
||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.network = activeNetwork.value;
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openCreateForm(network = activeNetwork.value) {
|
||||
activeNetwork.value = network;
|
||||
resetForm();
|
||||
form.network = network;
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
formError.value = null;
|
||||
|
||||
try {
|
||||
channelsStore.createChannel({
|
||||
name: form.name,
|
||||
network: form.network,
|
||||
});
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
formError.value = error.message ?? t('channels.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargets(value) {
|
||||
return (value ?? '')
|
||||
.split(/[,\n]+/)
|
||||
.map(target => target.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.create,
|
||||
createValue => {
|
||||
if (createValue === 'true') {
|
||||
openCreateForm(activeNetwork.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<h1>{{ t('channels.title') }}</h1>
|
||||
<p>{{ t('channels.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="network-tabs">
|
||||
<button
|
||||
v-for="network in networkOptions"
|
||||
:key="network.value"
|
||||
type="button"
|
||||
class="network-tab"
|
||||
:class="{ active: activeNetwork === network.value }"
|
||||
@click="activeNetwork = network.value"
|
||||
>
|
||||
<v-icon :icon="network.icon" />
|
||||
<span>{{ network.value }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFormVisible"
|
||||
class="create-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('channels.createTitle') }}</strong>
|
||||
<span>{{ form.network }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>{{ t('channels.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
type="button"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ t('channels.createTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="channelsForActiveNetwork.length"
|
||||
class="channel-grid"
|
||||
>
|
||||
<article
|
||||
v-for="channel in channelsForActiveNetwork"
|
||||
:key="channel.id"
|
||||
class="channel-card"
|
||||
>
|
||||
<div class="channel-header">
|
||||
<strong>{{ channel.name }}</strong>
|
||||
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="channel-metrics">
|
||||
<div>
|
||||
<small>{{ t('channels.metrics.scheduled') }}</small>
|
||||
<strong>{{ channel.scheduled }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>{{ t('channels.metrics.ready') }}</small>
|
||||
<strong>{{ channel.readyCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>{{ t('channels.metrics.blocked') }}</small>
|
||||
<strong>{{ channel.blockedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-footer">
|
||||
<span>{{ t('channels.nextDue') }}</span>
|
||||
<em>{{ channel.nextDueDate ? new Date(channel.nextDueDate).toLocaleDateString() : t('channels.noScheduled') }}</em>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="empty-state"
|
||||
@click="openCreateForm(activeNetwork)"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
<span>{{ t('channels.emptyAction', { network: activeNetwork }) }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p,
|
||||
.network-tab span,
|
||||
.channel-header span,
|
||||
.channel-footer span,
|
||||
.channel-footer em,
|
||||
.channel-metrics small,
|
||||
.page-message,
|
||||
.empty-state span {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.network-tabs {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.network-tab {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-4 py-3 transition;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.network-tab.active,
|
||||
.network-tab:hover {
|
||||
border-color: rgba(255, 138, 61, 0.28);
|
||||
background: rgba(255, 138, 61, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.channel-card,
|
||||
.create-panel,
|
||||
.empty-state {
|
||||
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply items-center justify-center text-center;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.panel-header strong,
|
||||
.field,
|
||||
.channel-header strong,
|
||||
.channel-metrics strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex justify-end gap-3;
|
||||
}
|
||||
|
||||
.channel-header,
|
||||
.channel-footer {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.channel-header strong {
|
||||
@apply text-xl font-black;
|
||||
}
|
||||
|
||||
.channel-metrics {
|
||||
@apply grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.channel-metrics div {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.channel-metrics strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
182
frontend/src/features/clients/stores/clientsStore.js
Normal file
182
frontend/src/features/clients/stores/clientsStore.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useClientsStore = defineStore('clients', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const clients = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
const isUploadingPortrait = ref(false);
|
||||
const error = ref(null);
|
||||
const operationalClient = computed(() => {
|
||||
if (!clients.value.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clients.value.find(candidate => candidate.name === workspaceStore.activeWorkspace?.name)
|
||||
?? clients.value[0];
|
||||
});
|
||||
|
||||
async function fetchClients() {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
clients.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/clients', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
clients.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch clients:', fetchError);
|
||||
clients.value = [];
|
||||
error.value = 'Failed to load clients.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createClient(payload) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
throw new Error('You must be authenticated to create a client.');
|
||||
}
|
||||
|
||||
if (isCreating.value) {
|
||||
throw new Error('A client creation request is already in progress.');
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/clients', {
|
||||
...payload,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
clients.value = [...clients.value, response.data]
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create client:', createError);
|
||||
error.value = 'Failed to create client.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClient(clientId, payload) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
throw new Error('You must be authenticated to update a client.');
|
||||
}
|
||||
|
||||
if (isUpdating.value) {
|
||||
throw new Error('A client update request is already in progress.');
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/clients/${clientId}`, payload);
|
||||
|
||||
if (response.data) {
|
||||
clients.value = clients.value
|
||||
.map(candidate => candidate.id === clientId ? response.data : candidate)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update client:', updateError);
|
||||
error.value = 'Failed to update client.';
|
||||
throw updateError;
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadClientPortrait(clientId, file) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
throw new Error('You must be authenticated to upload a client logo.');
|
||||
}
|
||||
|
||||
if (isUploadingPortrait.value) {
|
||||
throw new Error('A client logo upload is already in progress.');
|
||||
}
|
||||
|
||||
isUploadingPortrait.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name || 'client-logo.png');
|
||||
|
||||
const response = await client.post(`/api/clients/${clientId}/portrait`, formData);
|
||||
const blobUrl = response.data?.blobUrl;
|
||||
|
||||
if (blobUrl) {
|
||||
clients.value = clients.value.map(candidate =>
|
||||
candidate.id === clientId
|
||||
? { ...candidate, portraitUrl: `${blobUrl}?${Date.now()}` }
|
||||
: candidate
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload client logo:', uploadError);
|
||||
error.value = 'Failed to upload client logo.';
|
||||
throw uploadError;
|
||||
} finally {
|
||||
isUploadingPortrait.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||
async ([isAuthenticated, workspaceId]) => {
|
||||
if (!isAuthenticated || !workspaceId) {
|
||||
clients.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchClients();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
clients,
|
||||
operationalClient,
|
||||
isLoading,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isUploadingPortrait,
|
||||
error,
|
||||
fetchClients,
|
||||
createClient,
|
||||
updateClient,
|
||||
uploadClientPortrait,
|
||||
};
|
||||
});
|
||||
712
frontend/src/features/clients/views/ClientDetailView.vue
Normal file
712
frontend/src/features/clients/views/ClientDetailView.vue
Normal file
@@ -0,0 +1,712 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const clientsStore = useClientsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const isEditFormVisible = ref(false);
|
||||
const isPortraitDialogOpen = ref(false);
|
||||
const portraitDialogTarget = ref('client');
|
||||
const formError = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
status: 'Active',
|
||||
portraitUrl: '',
|
||||
primaryContactName: '',
|
||||
primaryContactEmail: '',
|
||||
primaryContactPortraitUrl: '',
|
||||
});
|
||||
const portraitDialogMeta = computed(() => {
|
||||
if (portraitDialogTarget.value === 'contact') {
|
||||
return {
|
||||
title: 'Update primary contact portrait',
|
||||
confirmLabel: 'Apply portrait',
|
||||
uploadLabel: 'Choose portrait',
|
||||
initialUrl: form.primaryContactPortraitUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Update client logo',
|
||||
confirmLabel: 'Apply logo',
|
||||
uploadLabel: 'Choose logo',
|
||||
initialUrl: form.portraitUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const client = computed(() =>
|
||||
clientsStore.clients.find(candidate => candidate.id === route.params.clientId) ?? null
|
||||
);
|
||||
|
||||
const scopedProjects = computed(() =>
|
||||
projectsStore.projects
|
||||
.filter(project => project.clientId === route.params.clientId)
|
||||
.sort((left, right) => {
|
||||
const leftDue = left.endDate ? new Date(left.endDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const rightDue = right.endDate ? new Date(right.endDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return leftDue - rightDue;
|
||||
})
|
||||
);
|
||||
|
||||
const currentProjects = computed(() =>
|
||||
scopedProjects.value.filter(project => project.status !== 'Completed' && project.status !== 'Archived')
|
||||
);
|
||||
|
||||
const pastProjects = computed(() =>
|
||||
scopedProjects.value.filter(project => project.status === 'Completed' || project.status === 'Archived')
|
||||
);
|
||||
|
||||
const itemCountByProjectId = computed(() => {
|
||||
const counts = new Map();
|
||||
|
||||
for (const item of contentItemsStore.items.filter(candidate => candidate.clientId === route.params.clientId)) {
|
||||
counts.set(item.projectId, (counts.get(item.projectId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
function formatProjectDateRange(project) {
|
||||
if (!project?.startDate || !project?.endDate) {
|
||||
return 'No date range';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(new Date(project.startDate), new Date(project.endDate));
|
||||
}
|
||||
|
||||
function syncForm() {
|
||||
if (!client.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.name = client.value.name ?? '';
|
||||
form.status = client.value.status ?? 'Active';
|
||||
form.portraitUrl = client.value.portraitUrl ?? '';
|
||||
form.primaryContactName = client.value.primaryContactName ?? '';
|
||||
form.primaryContactEmail = client.value.primaryContactEmail ?? '';
|
||||
form.primaryContactPortraitUrl = client.value.primaryContactPortraitUrl ?? '';
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openEditForm() {
|
||||
syncForm();
|
||||
isEditFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitEditForm() {
|
||||
if (!client.value || clientsStore.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
if (!form.name || !form.status) {
|
||||
formError.value = 'Client name and status are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientsStore.updateClient(client.value.id, {
|
||||
name: form.name,
|
||||
status: form.status,
|
||||
portraitUrl: form.portraitUrl,
|
||||
primaryContactName: form.primaryContactName,
|
||||
primaryContactEmail: form.primaryContactEmail,
|
||||
primaryContactPortraitUrl: form.primaryContactPortraitUrl,
|
||||
});
|
||||
|
||||
isEditFormVisible.value = false;
|
||||
} catch (error) {
|
||||
formError.value = 'The client could not be updated.';
|
||||
}
|
||||
}
|
||||
|
||||
function openPortraitDialog(target) {
|
||||
portraitDialogTarget.value = target;
|
||||
isPortraitDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function clearPortrait(target) {
|
||||
if (target === 'contact') {
|
||||
form.primaryContactPortraitUrl = '';
|
||||
return;
|
||||
}
|
||||
|
||||
form.portraitUrl = '';
|
||||
}
|
||||
|
||||
async function savePortraitImage(result) {
|
||||
if (portraitDialogTarget.value === 'contact') {
|
||||
form.primaryContactPortraitUrl = result.dataUrl;
|
||||
} else {
|
||||
form.portraitUrl = result.dataUrl;
|
||||
}
|
||||
|
||||
isPortraitDialogOpen.value = false;
|
||||
}
|
||||
|
||||
watch(client, () => {
|
||||
if (!isEditFormVisible.value) {
|
||||
syncForm();
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div
|
||||
v-if="!client"
|
||||
class="page-message error"
|
||||
>
|
||||
The selected client could not be found in the active workspace.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="hero">
|
||||
<div class="hero-main">
|
||||
<router-link
|
||||
class="breadcrumb"
|
||||
:to="{ name: 'clients' }"
|
||||
>
|
||||
Clients
|
||||
</router-link>
|
||||
<h1>{{ client.name }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="hero-status">{{ client.status }}</span>
|
||||
</div>
|
||||
<p>The client area scopes projects and content so review stays inside one account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<span>Current campaigns</span>
|
||||
<strong>{{ currentProjects.length }}</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>Past campaigns</span>
|
||||
<strong>{{ pastProjects.length }}</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>Total content items</span>
|
||||
<strong>{{ contentItemsStore.items.filter(item => item.clientId === client.id).length }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="scope-actions">
|
||||
<router-link
|
||||
v-if="authStore.isManager || authStore.isProvider"
|
||||
:to="{ name: 'content-item-create' }"
|
||||
class="scope-button"
|
||||
>
|
||||
New content for {{ client.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="section details-section">
|
||||
<div class="section-header">
|
||||
<strong>Client details</strong>
|
||||
<button
|
||||
v-if="authStore.isManager"
|
||||
class="scope-button scope-button-secondary"
|
||||
@click="isEditFormVisible ? (isEditFormVisible = false) : openEditForm()"
|
||||
>
|
||||
{{ isEditFormVisible ? 'Close editor' : 'Edit details' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isEditFormVisible"
|
||||
class="details-grid"
|
||||
>
|
||||
<div class="detail-row detail-row-wide">
|
||||
<span>Client</span>
|
||||
<div class="identity-row">
|
||||
<AppAvatar
|
||||
:name="client.name"
|
||||
:src="client.portraitUrl"
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ client.name }}</strong>
|
||||
<small>{{ client.status }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Primary contact</span>
|
||||
<strong>{{ client.primaryContactName || 'No primary contact set' }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Email</span>
|
||||
<strong>{{ client.primaryContactEmail || 'No primary contact email set' }}</strong>
|
||||
</div>
|
||||
<div class="detail-row detail-row-wide">
|
||||
<span>Primary contact portrait</span>
|
||||
<div class="identity-row">
|
||||
<AppAvatar
|
||||
:name="client.primaryContactName || client.primaryContactEmail || client.name"
|
||||
:src="client.primaryContactPortraitUrl"
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ client.primaryContactName || 'Contact portrait' }}</strong>
|
||||
<small>{{ client.primaryContactPortraitUrl ? 'Custom portrait set' : 'Using initials fallback' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field field-wide">
|
||||
<span>Client name</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Status</span>
|
||||
<select
|
||||
v-model="form.status"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Paused">Paused</option>
|
||||
<option value="Archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="field field-wide image-field">
|
||||
<span>Client logo</span>
|
||||
<div class="image-picker-card">
|
||||
<AppAvatar
|
||||
:name="form.name || client.name"
|
||||
:src="form.portraitUrl"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="image-picker-copy">
|
||||
<strong>{{ form.portraitUrl ? 'Custom logo selected' : 'No logo selected' }}</strong>
|
||||
<small>Use a local file or a remote image URL, then crop and scale it.</small>
|
||||
</div>
|
||||
<div class="image-picker-actions">
|
||||
<button
|
||||
class="scope-button scope-button-secondary"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
@click="openPortraitDialog('client')"
|
||||
>
|
||||
Change image
|
||||
</button>
|
||||
<button
|
||||
class="scope-button scope-button-secondary"
|
||||
:disabled="clientsStore.isUpdating || !form.portraitUrl"
|
||||
@click="clearPortrait('client')"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Primary contact name</span>
|
||||
<input
|
||||
v-model="form.primaryContactName"
|
||||
type="text"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Primary contact email</span>
|
||||
<input
|
||||
v-model="form.primaryContactEmail"
|
||||
type="email"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field field-wide image-field">
|
||||
<span>Primary contact portrait</span>
|
||||
<div class="image-picker-card">
|
||||
<AppAvatar
|
||||
:name="form.primaryContactName || form.primaryContactEmail || form.name"
|
||||
:src="form.primaryContactPortraitUrl"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="image-picker-copy">
|
||||
<strong>{{ form.primaryContactPortraitUrl ? 'Custom portrait selected' : 'No portrait selected' }}</strong>
|
||||
<small>Use a local file or a remote image URL, then crop and scale it.</small>
|
||||
</div>
|
||||
<div class="image-picker-actions">
|
||||
<button
|
||||
class="scope-button scope-button-secondary"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
@click="openPortraitDialog('contact')"
|
||||
>
|
||||
Change image
|
||||
</button>
|
||||
<button
|
||||
class="scope-button scope-button-secondary"
|
||||
:disabled="clientsStore.isUpdating || !form.primaryContactPortraitUrl"
|
||||
@click="clearPortrait('contact')"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="scope-button scope-button-secondary"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
@click="isEditFormVisible = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="scope-button"
|
||||
:disabled="clientsStore.isUpdating"
|
||||
@click="submitEditForm"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="clientsStore.isUpdating"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ clientsStore.isUpdating ? 'Saving...' : 'Save client' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isPortraitDialogOpen"
|
||||
:title="portraitDialogMeta.title"
|
||||
:confirm-label="portraitDialogMeta.confirmLabel"
|
||||
:upload-label="portraitDialogMeta.uploadLabel"
|
||||
:initial-url="portraitDialogMeta.initialUrl"
|
||||
:is-saving="clientsStore.isUpdating"
|
||||
@save="savePortraitImage"
|
||||
/>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<strong>Current campaigns</strong>
|
||||
<span>{{ currentProjects.length }} active</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentProjects.length"
|
||||
class="project-list"
|
||||
>
|
||||
<router-link
|
||||
v-for="project in currentProjects"
|
||||
:key="project.id"
|
||||
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
|
||||
class="project-card"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<span>{{ project.status }}</span>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
|
||||
<em>{{ formatProjectDateRange(project) }}</em>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="page-message"
|
||||
>
|
||||
No current campaigns are attached to this client.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<strong>Past campaigns</strong>
|
||||
<span>{{ pastProjects.length }} archived or completed</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pastProjects.length"
|
||||
class="project-list"
|
||||
>
|
||||
<router-link
|
||||
v-for="project in pastProjects"
|
||||
:key="project.id"
|
||||
:to="{ name: 'client-project-detail', params: { clientId: client.id, projectId: project.id } }"
|
||||
class="project-card muted"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<span>{{ project.status }}</span>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<small>{{ itemCountByProjectId.get(project.id) ?? 0 }} content items</small>
|
||||
<em>{{ formatProjectDateRange(project) }}</em>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.stat-card,
|
||||
.project-card {
|
||||
@apply rounded-[1.5rem] border;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply flex flex-col gap-4 p-6;
|
||||
}
|
||||
|
||||
.hero-main h1,
|
||||
.stat-card strong,
|
||||
.project-card strong,
|
||||
.contact-card strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-main h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
}
|
||||
|
||||
.hero-main p,
|
||||
.breadcrumb,
|
||||
.stat-card span,
|
||||
.project-card span,
|
||||
.project-card small,
|
||||
.project-card em,
|
||||
.section-header span {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
@apply font-bold uppercase tracking-[0.18em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
@apply mt-3 flex items-center gap-3;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
@apply inline-flex items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
@apply grid gap-4 md:grid-cols-3;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply p-5;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
@apply mt-3 block text-4xl font-black;
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
@apply rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.scope-actions {
|
||||
@apply flex flex-wrap justify-start gap-3;
|
||||
}
|
||||
|
||||
.scope-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.scope-button:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.scope-button-secondary {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #172033;
|
||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.scope-button-secondary:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.detail-row-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.detail-row small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.identity-row {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.identity-row div {
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
|
||||
.identity-row strong {
|
||||
@apply truncate text-base font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: white;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.image-field {
|
||||
@apply gap-3;
|
||||
}
|
||||
|
||||
.image-picker-card {
|
||||
@apply flex flex-col gap-4 rounded-[1.25rem] border p-4 lg:flex-row lg:items-center lg:justify-between;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.image-picker-copy {
|
||||
@apply flex min-w-0 flex-1 flex-col gap-1;
|
||||
}
|
||||
|
||||
.image-picker-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.image-picker-copy small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.image-picker-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:justify-end;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.section-header strong {
|
||||
@apply text-lg font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
@apply flex flex-col gap-4 p-5 no-underline transition;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.project-card.muted {
|
||||
background: rgba(255, 250, 242, 0.88);
|
||||
}
|
||||
|
||||
.project-card span {
|
||||
@apply uppercase tracking-[0.16em];
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/features/clients/views/ClientsView.vue
Normal file
366
frontend/src/features/clients/views/ClientsView.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const clientsStore = useClientsStore();
|
||||
const { t } = useI18n();
|
||||
const isCreateFormVisible = ref(false);
|
||||
const formError = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
portraitUrl: '',
|
||||
primaryContactName: '',
|
||||
primaryContactEmail: '',
|
||||
primaryContactPortraitUrl: '',
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.portraitUrl = '';
|
||||
form.primaryContactName = '';
|
||||
form.primaryContactEmail = '';
|
||||
form.primaryContactPortraitUrl = '';
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
resetForm();
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (clientsStore.isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
if (!form.name) {
|
||||
formError.value = t('clients.errors.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientsStore.createClient({
|
||||
name: form.name,
|
||||
portraitUrl: form.portraitUrl,
|
||||
primaryContactName: form.primaryContactName,
|
||||
primaryContactEmail: form.primaryContactEmail,
|
||||
primaryContactPortraitUrl: form.primaryContactPortraitUrl,
|
||||
});
|
||||
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
formError.value = t('clients.errors.createFailed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('clients.eyebrow') }}</div>
|
||||
<h1>{{ t('clients.title') }}</h1>
|
||||
<p>{{ t('clients.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button
|
||||
v-if="authStore.isManager"
|
||||
class="create-button"
|
||||
@click="openCreateForm"
|
||||
>
|
||||
{{ t('clients.newClient') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFormVisible"
|
||||
class="create-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('clients.createTitle') }}</strong>
|
||||
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.portraitUrl') }}</span>
|
||||
<input
|
||||
v-model="form.portraitUrl"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('clients.fields.primaryContactName') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactName"
|
||||
type="text"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('clients.fields.primaryContactEmail') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactEmail"
|
||||
type="email"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.primaryContactPortraitUrl') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactPortraitUrl"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="clientsStore.isCreating"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="clientsStore.isCreating"
|
||||
@click="submitForm"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="clientsStore.isCreating"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ clientsStore.isCreating ? t('common.creating') : t('clients.createTitle') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="clientsStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('clients.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="clientsStore.error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ clientsStore.error }}
|
||||
</div>
|
||||
|
||||
<div class="grid-list">
|
||||
<router-link
|
||||
v-for="client in clientsStore.clients"
|
||||
:key="client.id"
|
||||
:to="{ name: 'client-detail', params: { clientId: client.id } }"
|
||||
class="client-card"
|
||||
>
|
||||
<div class="client-card-header">
|
||||
<div>
|
||||
<strong>{{ client.name }}</strong>
|
||||
<span>{{ client.status }}</span>
|
||||
</div>
|
||||
<AppAvatar
|
||||
:name="client.name"
|
||||
:src="client.portraitUrl"
|
||||
/>
|
||||
</div>
|
||||
<em>{{ client.primaryContactName || t('clients.noPrimaryContact') }}</em>
|
||||
<small>{{ client.primaryContactEmail || t('clients.noPrimaryContactEmail') }}</small>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!clientsStore.isLoading && !clientsStore.clients.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('clients.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p {
|
||||
@apply mt-2 text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@apply flex justify-start;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center gap-2;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.create-button:hover,
|
||||
.primary:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex flex-col gap-1 md:flex-row md:items-center md:justify-between;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
@apply text-lg font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: white;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:justify-end;
|
||||
}
|
||||
|
||||
.grid-list {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
@apply flex flex-col gap-3 rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.client-card strong {
|
||||
@apply text-xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.client-card span {
|
||||
@apply text-sm font-semibold uppercase tracking-[0.18em];
|
||||
color: #ff8a3d;
|
||||
}
|
||||
|
||||
.client-card em {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.client-card small {
|
||||
@apply text-xs leading-5;
|
||||
color: #7b8798;
|
||||
}
|
||||
</style>
|
||||
255
frontend/src/features/content/stores/contentItemDetailStore.js
Normal file
255
frontend/src/features/content/stores/contentItemDetailStore.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useContentItemDetailStore = defineStore('content-item-detail', () => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const item = ref(null);
|
||||
const revisions = ref([]);
|
||||
const assets = ref([]);
|
||||
const comments = ref([]);
|
||||
const approvals = ref([]);
|
||||
const notifications = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const actions = reactive({
|
||||
revision: false,
|
||||
asset: false,
|
||||
assetRevision: false,
|
||||
comment: false,
|
||||
approval: false,
|
||||
decision: false,
|
||||
status: false,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
item.value = null;
|
||||
revisions.value = [];
|
||||
assets.value = [];
|
||||
comments.value = [];
|
||||
approvals.value = [];
|
||||
notifications.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
async function fetchContentItemDetail(contentItemId) {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const [
|
||||
itemResponse,
|
||||
revisionsResponse,
|
||||
assetsResponse,
|
||||
commentsResponse,
|
||||
approvalsResponse,
|
||||
notificationsResponse,
|
||||
] = await Promise.all([
|
||||
client.get(`/api/content-items/${contentItemId}`),
|
||||
client.get(`/api/content-items/${contentItemId}/revisions`),
|
||||
client.get('/api/assets', { params: { contentItemId } }),
|
||||
client.get('/api/comments', { params: { contentItemId } }),
|
||||
client.get('/api/approvals', { params: { contentItemId } }),
|
||||
client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
contentItemId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
item.value = itemResponse.data;
|
||||
revisions.value = revisionsResponse.data ?? [];
|
||||
assets.value = assetsResponse.data ?? [];
|
||||
comments.value = commentsResponse.data ?? [];
|
||||
approvals.value = approvalsResponse.data ?? [];
|
||||
notifications.value = notificationsResponse.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to load content item detail:', fetchError);
|
||||
reset();
|
||||
error.value = 'Failed to load the content item detail.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRevision(contentItemId, payload) {
|
||||
actions.revision = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/content-items/${contentItemId}/revisions`, payload);
|
||||
if (response.data) {
|
||||
revisions.value = [response.data, ...revisions.value];
|
||||
await fetchContentItemDetail(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.revision = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addGoogleDriveAsset(contentItemId, payload) {
|
||||
actions.asset = true;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/assets/google-drive', {
|
||||
...payload,
|
||||
contentItemId,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
if (response.data) {
|
||||
assets.value = [...assets.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.asset = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addAssetRevision(contentItemId, assetId, payload) {
|
||||
actions.assetRevision = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/assets/${assetId}/revisions`, payload);
|
||||
if (response.data) {
|
||||
await fetchAssets(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.assetRevision = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(contentItemId, payload) {
|
||||
actions.comment = true;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/comments', {
|
||||
...payload,
|
||||
contentItemId,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
if (response.data) {
|
||||
comments.value = [...comments.value, response.data];
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.comment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveComment(contentItemId, commentId) {
|
||||
actions.comment = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/comments/${commentId}/resolve`);
|
||||
if (response.data) {
|
||||
comments.value = comments.value.map(comment => comment.id === commentId ? response.data : comment);
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.comment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createApproval(contentItemId, payload) {
|
||||
actions.approval = true;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/approvals', {
|
||||
...payload,
|
||||
contentItemId,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
if (response.data) {
|
||||
approvals.value = [response.data, ...approvals.value];
|
||||
await fetchContentItem(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.approval = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDecision(contentItemId, approvalId, payload) {
|
||||
actions.decision = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/approvals/${approvalId}/decisions`, payload);
|
||||
if (response.data) {
|
||||
approvals.value = approvals.value.map(approval => approval.id === approvalId ? response.data : approval);
|
||||
await fetchContentItem(contentItemId);
|
||||
await fetchNotifications(contentItemId);
|
||||
}
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.decision = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(contentItemId, status) {
|
||||
actions.status = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/content-items/${contentItemId}/status`, { status });
|
||||
item.value = response.data;
|
||||
await fetchNotifications(contentItemId);
|
||||
return response.data;
|
||||
} finally {
|
||||
actions.status = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContentItem(contentItemId) {
|
||||
const response = await client.get(`/api/content-items/${contentItemId}`);
|
||||
item.value = response.data;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function fetchAssets(contentItemId) {
|
||||
const response = await client.get('/api/assets', { params: { contentItemId } });
|
||||
assets.value = response.data ?? [];
|
||||
return assets.value;
|
||||
}
|
||||
|
||||
async function fetchNotifications(contentItemId) {
|
||||
const response = await client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
contentItemId,
|
||||
},
|
||||
});
|
||||
notifications.value = response.data ?? [];
|
||||
return notifications.value;
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
revisions,
|
||||
assets,
|
||||
comments,
|
||||
approvals,
|
||||
notifications,
|
||||
isLoading,
|
||||
error,
|
||||
actions,
|
||||
reset,
|
||||
fetchContentItemDetail,
|
||||
createRevision,
|
||||
addGoogleDriveAsset,
|
||||
addAssetRevision,
|
||||
addComment,
|
||||
resolveComment,
|
||||
createApproval,
|
||||
submitDecision,
|
||||
updateStatus,
|
||||
};
|
||||
});
|
||||
112
frontend/src/features/content/stores/contentItemsStore.js
Normal file
112
frontend/src/features/content/stores/contentItemsStore.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useContentItemsStore = defineStore('content-items', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const items = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const activeCount = computed(() =>
|
||||
items.value.filter(item => item.status !== 'Approved' && item.status !== 'Published' && item.status !== 'Archived')
|
||||
.length
|
||||
);
|
||||
|
||||
async function fetchContentItems(filters = {}) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
items.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/content-items', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
clientId: filters.clientId,
|
||||
projectId: filters.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
items.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch content items:', fetchError);
|
||||
items.value = [];
|
||||
error.value = 'Failed to load content items.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createContentItem(payload) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
throw new Error('You must be authenticated to create a content item.');
|
||||
}
|
||||
|
||||
if (isCreating.value) {
|
||||
throw new Error('A content item creation request is already in progress.');
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/content-items', {
|
||||
...payload,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
items.value = [response.data, ...items.value];
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create content item:', createError);
|
||||
error.value = 'Failed to create content item.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContentItem(id) {
|
||||
const response = await client.get(`/api/content-items/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||
async ([isAuthenticated, workspaceId]) => {
|
||||
if (!isAuthenticated || !workspaceId) {
|
||||
items.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchContentItems();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
activeCount,
|
||||
fetchContentItems,
|
||||
fetchContentItem,
|
||||
createContentItem,
|
||||
};
|
||||
});
|
||||
1201
frontend/src/features/content/views/ContentItemDetailView.vue
Normal file
1201
frontend/src/features/content/views/ContentItemDetailView.vue
Normal file
File diff suppressed because it is too large
Load Diff
146
frontend/src/features/content/views/ContentItemsView.vue
Normal file
146
frontend/src/features/content/views/ContentItemsView.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('contentItems.eyebrow') }}</div>
|
||||
<h1>{{ t('contentItems.title') }}</h1>
|
||||
<p>{{ t('contentItems.description') }}</p>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
v-if="authStore.isManager || authStore.isProvider"
|
||||
:to="{ name: 'content-item-create' }"
|
||||
class="create-button"
|
||||
>
|
||||
{{ t('contentItems.newItem') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentItemsStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('contentItems.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="contentItemsStore.error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ contentItemsStore.error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="contentItemsStore.items.length"
|
||||
class="item-grid"
|
||||
>
|
||||
<router-link
|
||||
v-for="item in contentItemsStore.items"
|
||||
:key="item.id"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id } }"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ item.status }}</em>
|
||||
<small>{{ item.dueDate ? new Date(item.dueDate).toLocaleDateString() : t('contentItems.noDueDate') }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('contentItems.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p,
|
||||
.item-card span,
|
||||
.status-row em,
|
||||
.status-row small {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.page-message,
|
||||
.item-card {
|
||||
@apply rounded-[1.5rem] border;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply p-5 text-sm;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
@apply flex flex-col gap-4 p-5 no-underline transition;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.item-card strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
@apply w-fit rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
</style>
|
||||
222
frontend/src/features/content/views/MediaLibraryView.vue
Normal file
222
frontend/src/features/content/views/MediaLibraryView.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
mdiCheckCircleOutline,
|
||||
mdiCloudSyncOutline,
|
||||
mdiFolderGoogleDrive,
|
||||
mdiImageMultipleOutline,
|
||||
mdiVideoOutline,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const mediaTypes = [
|
||||
{ label: t('mediaLibrary.mediaTypes.images'), icon: mdiImageMultipleOutline },
|
||||
{ label: t('mediaLibrary.mediaTypes.videos'), icon: mdiVideoOutline },
|
||||
];
|
||||
|
||||
const workflowSteps = [
|
||||
t('mediaLibrary.workflow.connectDrive'),
|
||||
t('mediaLibrary.workflow.syncAssets'),
|
||||
t('mediaLibrary.workflow.organizeLibrary'),
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">{{ t('mediaLibrary.eyebrow') }}</div>
|
||||
<h1>{{ t('mediaLibrary.title') }}</h1>
|
||||
<p>{{ t('mediaLibrary.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-card">
|
||||
<div class="hero-card-icon">
|
||||
<v-icon :icon="mdiFolderGoogleDrive" />
|
||||
</div>
|
||||
<strong>{{ t('mediaLibrary.syncCard.title') }}</strong>
|
||||
<span>{{ t('mediaLibrary.syncCard.description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('mediaLibrary.mediaTypesTitle') }}</strong>
|
||||
<span>{{ t('mediaLibrary.mediaTypesDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="media-type-list">
|
||||
<div
|
||||
v-for="type in mediaTypes"
|
||||
:key="type.label"
|
||||
class="media-type-item"
|
||||
>
|
||||
<v-icon :icon="type.icon" />
|
||||
<span>{{ type.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('mediaLibrary.workflowTitle') }}</strong>
|
||||
<span>{{ t('mediaLibrary.workflowDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="workflow-list">
|
||||
<div
|
||||
v-for="step in workflowSteps"
|
||||
:key="step"
|
||||
class="workflow-item"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiCheckCircleOutline"
|
||||
class="workflow-icon"
|
||||
/>
|
||||
<span>{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="status-panel">
|
||||
<div class="status-copy">
|
||||
<div class="status-label">
|
||||
<v-icon :icon="mdiCloudSyncOutline" />
|
||||
<span>{{ t('mediaLibrary.statusLabel') }}</span>
|
||||
</div>
|
||||
<strong>{{ t('mediaLibrary.pendingTitle') }}</strong>
|
||||
<p>{{ t('mediaLibrary.pendingDescription') }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply grid gap-4 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.8fr)];
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.hero-card,
|
||||
.panel,
|
||||
.status-panel {
|
||||
@apply rounded-[1.75rem] border;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
@apply p-6 md:p-8;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(14, 165, 164, 0.18), transparent 45%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.92));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
@apply mt-3 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.hero-card span,
|
||||
.panel-header span,
|
||||
.media-type-item span,
|
||||
.workflow-item span,
|
||||
.status-copy p,
|
||||
.status-label span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@apply flex flex-col justify-between gap-5 p-6;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 250, 242, 0.96), rgba(255, 255, 255, 0.96));
|
||||
}
|
||||
|
||||
.hero-card-icon,
|
||||
.media-type-item,
|
||||
.workflow-item,
|
||||
.status-label {
|
||||
@apply inline-flex items-center gap-3;
|
||||
}
|
||||
|
||||
.hero-card-icon,
|
||||
.media-type-item {
|
||||
@apply w-fit rounded-full px-3 py-2;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.hero-card strong,
|
||||
.panel-header strong,
|
||||
.status-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-card strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
@apply grid gap-6 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.status-panel {
|
||||
@apply flex flex-col gap-5 p-6;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
@apply text-xl font-black;
|
||||
}
|
||||
|
||||
.media-type-list,
|
||||
.workflow-list {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.media-type-item,
|
||||
.workflow-item {
|
||||
@apply rounded-[1.1rem] border px-4 py-3;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.workflow-icon {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
.status-copy {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-copy strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/features/landing/svg/Facebook.vue
Normal file
9
frontend/src/features/landing/svg/Facebook.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
12
frontend/src/features/landing/svg/Hutopy.vue
Normal file
12
frontend/src/features/landing/svg/Hutopy.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 540 540"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 0 C2.69849689 0.0031451 5.39587704 -0.02033719 8.09423828 -0.0456543 C24.64095924 -0.10755047 40.6877731 1.83145667 56.59814453 6.50317383 C57.64365479 6.80505615 58.68916504 7.10693848 59.76635742 7.41796875 C92.79656028 17.21576522 120.14877162 34.84217931 145.09814453 58.31567383 C145.80712891 58.97051758 146.51611328 59.62536133 147.24658203 60.30004883 C156.43027452 69.07141226 164.10800524 79.75645166 171.09814453 90.31567383 C171.99726562 91.64985352 171.99726562 91.64985352 172.91455078 93.01098633 C202.24624509 138.40104257 210.21713133 193.46657021 199.45556641 245.92919922 C192.90962056 275.68852135 179.08829353 305.20779517 159.09814453 328.31567383 C158.12705075 329.46844293 157.15708766 330.62216508 156.18798828 331.77661133 C145.74534244 344.15953661 145.74534244 344.15953661 140.09814453 349.31567383 C139.43814453 349.31567383 138.77814453 349.31567383 138.09814453 349.31567383 C138.09814453 349.97567383 138.09814453 350.63567383 138.09814453 351.31567383 C125.46428062 363.48565937 109.61785837 373.30307857 94.09814453 381.31567383 C93.49325195 381.62843262 92.88835937 381.94119141 92.26513672 382.26342773 C64.72703152 396.27155233 34.49385055 402.76232703 3.72314453 402.69067383 C2.84274506 402.69013 1.96234558 402.68958618 1.05526733 402.68902588 C-13.25349056 402.64848426 -26.95433873 401.73425935 -40.90185547 398.31567383 C-42.23821045 397.99421387 -42.23821045 397.99421387 -43.6015625 397.66625977 C-72.07884569 390.61747809 -98.87807183 378.00655878 -121.53857422 359.26879883 C-124.98230298 356.422742 -128.53024689 353.7103623 -132.06591797 350.97973633 C-132.97470703 350.15602539 -132.97470703 350.15602539 -133.90185547 349.31567383 C-133.90185547 348.65567383 -133.90185547 347.99567383 -133.90185547 347.31567383 C-134.56185547 347.31567383 -135.22185547 347.31567383 -135.90185547 347.31567383 C-135.90185547 346.65567383 -135.90185547 345.99567383 -135.90185547 345.31567383 C-136.56185547 345.31567383 -137.22185547 345.31567383 -137.90185547 345.31567383 C-139.24169922 343.98754883 -139.24169922 343.98754883 -140.83935547 342.06567383 C-143.10188476 339.38611053 -145.39815773 336.76489417 -147.80810547 334.21801758 C-148.30834229 333.68660156 -148.8085791 333.15518555 -149.32397461 332.60766602 C-150.32555716 331.54718583 -151.33248849 330.49172924 -152.3449707 329.44165039 C-155.90185547 325.64913211 -155.90185547 325.64913211 -155.90185547 322.31567383 C-151.7526342 322.86293323 -149.09751951 325.32327358 -145.96435547 327.87817383 C-112.49996321 354.28872765 -70.28107399 364.92725762 -28.16088867 360.37231445 C11.7404784 355.37903429 50.51367105 333.83116901 75.28564453 302.00317383 C102.2073581 266.1942078 113.74287629 223.77918143 108.03564453 179.19067383 C103.36182966 146.79964046 88.57557441 116.98148746 66.09814453 93.31567383 C65.60604492 92.78570801 65.11394531 92.25574219 64.60693359 91.7097168 C40.8738918 66.25446714 8.58347239 50.05740641 -25.90185547 45.31567383 C-27.07103516 45.13391602 -28.24021484 44.9521582 -29.44482422 44.76489258 C-71.61533582 39.65438979 -112.89208728 51.7135382 -146.31201172 77.46020508 C-149.8276055 80.21952147 -153.19809546 83.10839876 -156.42138672 86.20629883 C-157.90185547 87.31567383 -157.90185547 87.31567383 -160.90185547 87.31567383 C-160.35570146 83.08040407 -157.80081795 80.22554824 -155.21435547 77.00317383 C-154.72813721 76.3936084 -154.24191895 75.78404297 -153.7409668 75.15600586 C-143.89916607 62.99985508 -133.13277345 52.06245136 -120.90185547 42.31567383 C-120.33450684 41.85741211 -119.7671582 41.39915039 -119.18261719 40.92700195 C-97.55281967 23.63297615 -71.10136924 11.56026197 -44.27685547 5.06567383 C-43.26478027 4.81954346 -42.25270508 4.57341309 -41.20996094 4.31982422 C-27.42871573 1.10789603 -14.10433559 -0.03420308 0 0 Z "
|
||||
transform="translate(258.90185546875,69.684326171875)"/>
|
||||
<path
|
||||
d="M0 0 C23.93418963 20.21356155 38.18828481 47.87411238 43.30859375 78.609375 C43.72796962 83.82104607 43.79621346 89.00908218 43.74609375 94.234375 C43.7423877 94.93147583 43.73868164 95.62857666 43.73486328 96.34680176 C43.51079954 126.08447361 33.84943539 152.98108038 14.30859375 175.609375 C13.37273437 176.74439453 13.37273437 176.74439453 12.41796875 177.90234375 C7.3119221 183.93204517 1.58692987 188.83219983 -4.69140625 193.609375 C-5.65691406 194.351875 -6.62242187 195.094375 -7.6171875 195.859375 C-33.76277727 214.99515608 -66.44544142 222.27976227 -98.37890625 217.421875 C-129.2224086 212.211016 -156.10496998 196.18588064 -175.99609375 172.10546875 C-177.69140625 169.609375 -177.69140625 169.609375 -177.69140625 166.609375 C-176.37140625 166.939375 -175.05140625 167.269375 -173.69140625 167.609375 C-173.69140625 168.269375 -173.69140625 168.929375 -173.69140625 169.609375 C-172.85222656 170.00576172 -172.85222656 170.00576172 -171.99609375 170.41015625 C-169.47801141 171.72041265 -167.18096648 173.22649078 -164.81640625 174.796875 C-141.95286732 189.35191305 -116.13355444 194.94004479 -89.48388672 189.12792969 C-74.00965166 185.43003002 -61.09388018 178.62819028 -48.69140625 168.609375 C-48.05976563 168.14273438 -47.428125 167.67609375 -46.77734375 167.1953125 C-29.95140734 154.19707727 -19.18769244 130.97695501 -16.03515625 110.39453125 C-15.865 109.01587891 -15.865 109.01587891 -15.69140625 107.609375 C-15.55734375 106.55621094 -15.42328125 105.50304687 -15.28515625 104.41796875 C-12.8813248 80.40697048 -19.85309354 55.11934523 -34.69921875 36.02734375 C-35.35664063 35.22941406 -36.0140625 34.43148437 -36.69140625 33.609375 C-37.32820312 32.82175781 -37.965 32.03414062 -38.62109375 31.22265625 C-54.38763523 12.57508008 -77.98681734 0.98006353 -102.19970703 -1.04858398 C-115.33840527 -1.9297872 -128.14870541 -0.56547477 -140.69140625 3.609375 C-141.71621094 3.94710937 -142.74101562 4.28484375 -143.796875 4.6328125 C-155.05737548 8.64192736 -164.81345042 14.73678833 -173.984375 22.359375 C-175.69140625 23.609375 -175.69140625 23.609375 -177.69140625 23.609375 C-176.02661937 12.90007435 -162.17010016 2.50403181 -153.91601562 -3.73193359 C-107.16913616 -37.54750108 -44.81974776 -36.84949238 0 0 Z "
|
||||
transform="translate(291.69140625,175.390625)"/>
|
||||
</svg>
|
||||
</template>
|
||||
9
frontend/src/features/landing/svg/Instagram.vue
Normal file
9
frontend/src/features/landing/svg/Instagram.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 448 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/>
|
||||
</svg>
|
||||
</template>
|
||||
9
frontend/src/features/landing/svg/Linkedin.vue
Normal file
9
frontend/src/features/landing/svg/Linkedin.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 448 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"/>
|
||||
</svg>
|
||||
</template>
|
||||
9
frontend/src/features/landing/svg/Reddit.vue
Normal file
9
frontend/src/features/landing/svg/Reddit.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M0 256C0 114.6 114.6 0 256 0S512 114.6 512 256s-114.6 256-256 256L37.1 512c-13.7 0-20.5-16.5-10.9-26.2L75 437C28.7 390.7 0 326.7 0 256zM349.6 153.6c23.6 0 42.7-19.1 42.7-42.7s-19.1-42.7-42.7-42.7c-20.6 0-37.8 14.6-41.8 34c-34.5 3.7-61.4 33-61.4 68.4l0 .2c-37.5 1.6-71.8 12.3-99 29.1c-10.1-7.8-22.8-12.5-36.5-12.5c-33 0-59.8 26.8-59.8 59.8c0 24 14.1 44.6 34.4 54.1c2 69.4 77.6 125.2 170.6 125.2s168.7-55.9 170.6-125.3c20.2-9.6 34.1-30.2 34.1-54c0-33-26.8-59.8-59.8-59.8c-13.7 0-26.3 4.6-36.4 12.4c-27.4-17-62.1-27.7-100-29.1l0-.2c0-25.4 18.9-46.5 43.4-49.9l0 0c4.4 18.8 21.3 32.8 41.5 32.8zM177.1 246.9c16.7 0 29.5 17.6 28.5 39.3s-13.5 29.6-30.3 29.6s-31.4-8.8-30.4-30.5s15.4-38.3 32.1-38.3zm190.1 38.3c1 21.7-13.7 30.5-30.4 30.5s-29.3-7.9-30.3-29.6c-1-21.7 11.8-39.3 28.5-39.3s31.2 16.6 32.1 38.3zm-48.1 56.7c-10.3 24.6-34.6 41.9-63 41.9s-52.7-17.3-63-41.9c-1.2-2.9 .8-6.2 3.9-6.5c18.4-1.9 38.3-2.9 59.1-2.9s40.7 1 59.1 2.9c3.1 .3 5.1 3.6 3.9 6.5z"/>
|
||||
</svg>
|
||||
</template>
|
||||
8
frontend/src/features/landing/svg/Tiktok.vue
Normal file
8
frontend/src/features/landing/svg/Tiktok.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 448 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/>
|
||||
</svg>
|
||||
</template>
|
||||
9
frontend/src/features/landing/svg/Web.vue
Normal file
9
frontend/src/features/landing/svg/Web.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
9
frontend/src/features/landing/svg/X.vue
Normal file
9
frontend/src/features/landing/svg/X.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"/>
|
||||
</svg>
|
||||
</template>
|
||||
10
frontend/src/features/landing/svg/Youtube.vue
Normal file
10
frontend/src/features/landing/svg/Youtube.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg fill="currentColor"
|
||||
viewBox="0 0 576 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. -->
|
||||
<path
|
||||
d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"/>
|
||||
</svg>
|
||||
</template>
|
||||
192
frontend/src/features/landing/views/Landing.vue
Normal file
192
frontend/src/features/landing/views/Landing.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const pillars = computed(() => [
|
||||
{
|
||||
eyebrow: 'Single source of truth',
|
||||
title: 'Comments, revisions, decisions, and due dates stay attached to one content item.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Built for agencies',
|
||||
title: 'Coordinate internal teams, providers, and client approvers without chasing email threads.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Google Drive first',
|
||||
title: 'Keep Drive as the asset owner when clients require it, while centralizing workflow in Socialize.',
|
||||
},
|
||||
]);
|
||||
|
||||
const workflow = computed(() => [
|
||||
'Create a content item with copy, targets, due dates, and review notes.',
|
||||
'Attach Google Drive assets and register revisions as feedback comes in.',
|
||||
'Request internal review, then client approval, with a clear audit trail.',
|
||||
'Mark the item ready for publishing handoff once approvals are complete.',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="landing-shell">
|
||||
<section class="hero-card">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">Social media approval workflow</div>
|
||||
<h1>Replace Drive links, scattered comments, and manual follow-up with one review system.</h1>
|
||||
<p>
|
||||
Socialize is being rebuilt as an agency workflow product for content review, revision tracking,
|
||||
client approval, and publication readiness.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<router-link to="/login">
|
||||
<button class="primary">Open the app</button>
|
||||
</router-link>
|
||||
<router-link to="/register">
|
||||
<button class="secondary">Create an internal account</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-panel">
|
||||
<div class="hero-panel-title">Version 1 workflow</div>
|
||||
<ol class="workflow-list">
|
||||
<li
|
||||
v-for="step in workflow"
|
||||
:key="step"
|
||||
>
|
||||
{{ step }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pillars-grid">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="pillar-card"
|
||||
>
|
||||
<div class="eyebrow">{{ pillar.eyebrow }}</div>
|
||||
<p>{{ pillar.title }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="focus-card">
|
||||
<div>
|
||||
<div class="eyebrow">Current build focus</div>
|
||||
<h2>Phase 1 into Phase 2: retire the creator product surface and install the workflow domain shell.</h2>
|
||||
</div>
|
||||
<div class="focus-metrics">
|
||||
<div>
|
||||
<strong>Clients</strong>
|
||||
<span>Brands and businesses under one workspace</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Campaigns</strong>
|
||||
<span>Grouped work tied to timelines, approvals, and delivery goals</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Content items</strong>
|
||||
<span>Reviewable units with assets, copy, and approvals</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-8 px-5 py-8 md:px-8 md:py-12;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
@apply grid gap-6 rounded-[2rem] p-6 md:grid-cols-[1.4fr_0.9fr] md:p-10;
|
||||
background: linear-gradient(145deg, #172033 0%, #25324b 65%, #314766 100%);
|
||||
color: #fffaf2;
|
||||
box-shadow: 0 30px 80px rgba(23, 32, 51, 0.18);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
@apply max-w-3xl text-4xl font-black leading-tight md:text-6xl;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
@apply max-w-2xl text-base leading-7 md:text-lg;
|
||||
color: rgba(255, 250, 242, 0.84);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.26em];
|
||||
color: #ffb26b;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
@apply rounded-[1.5rem] p-6;
|
||||
background: rgba(255, 250, 242, 0.08);
|
||||
border: 1px solid rgba(255, 250, 242, 0.12);
|
||||
}
|
||||
|
||||
.hero-panel-title {
|
||||
@apply mb-4 text-sm font-bold uppercase tracking-[0.22em];
|
||||
color: #7dd3c7;
|
||||
}
|
||||
|
||||
.workflow-list {
|
||||
@apply flex list-decimal flex-col gap-4 pl-5;
|
||||
}
|
||||
|
||||
.workflow-list li {
|
||||
@apply text-sm leading-6 md:text-base;
|
||||
}
|
||||
|
||||
.pillars-grid {
|
||||
@apply grid gap-4 md:grid-cols-3;
|
||||
}
|
||||
|
||||
.pillar-card {
|
||||
@apply rounded-[1.5rem] p-6;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.pillar-card p {
|
||||
@apply mt-3 text-lg font-semibold leading-7;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.focus-card {
|
||||
@apply grid gap-6 rounded-[1.75rem] p-6 md:grid-cols-[1fr_1.1fr] md:p-8;
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.12), rgba(52, 211, 153, 0.1));
|
||||
border: 1px solid rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.focus-card h2 {
|
||||
@apply mt-3 text-2xl font-black leading-tight md:text-3xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.focus-metrics {
|
||||
@apply grid gap-4 md:grid-cols-3;
|
||||
}
|
||||
|
||||
.focus-metrics div {
|
||||
@apply rounded-[1.25rem] bg-white/70 p-5;
|
||||
border: 1px solid rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.focus-metrics strong {
|
||||
@apply block text-sm font-black uppercase tracking-[0.18em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.focus-metrics span {
|
||||
@apply mt-2 block text-sm leading-6;
|
||||
color: #3f4d63;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const items = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const unreadCount = computed(() =>
|
||||
items.value.filter(item => !item.readAt).length
|
||||
);
|
||||
|
||||
const recentItems = computed(() => items.value.slice(0, 6));
|
||||
|
||||
function reset() {
|
||||
items.value = [];
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
items.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch notifications:', fetchError);
|
||||
items.value = [];
|
||||
error.value = 'Failed to load notifications.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(notificationId) {
|
||||
try {
|
||||
await client.post(`/api/notifications/${notificationId}/read`);
|
||||
items.value = items.value.map(item =>
|
||||
item.id === notificationId
|
||||
? { ...item, readAt: item.readAt ?? new Date().toISOString() }
|
||||
: item
|
||||
);
|
||||
} catch (markError) {
|
||||
console.error('Failed to mark notification as read:', markError);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||
async ([isAuthenticated, workspaceId]) => {
|
||||
if (!isAuthenticated || !workspaceId) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchNotifications();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
recentItems,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
reset,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
};
|
||||
});
|
||||
99
frontend/src/features/projects/stores/projectsStore.js
Normal file
99
frontend/src/features/projects/stores/projectsStore.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const projects = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetchProjects() {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
projects.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/projects', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
projects.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch projects:', fetchError);
|
||||
projects.value = [];
|
||||
error.value = 'Failed to load projects.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(payload) {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
throw new Error('You must be authenticated to create a project.');
|
||||
}
|
||||
|
||||
if (isCreating.value) {
|
||||
throw new Error('A project creation request is already in progress.');
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/projects', {
|
||||
...payload,
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
projects.value = [...projects.value, response.data]
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create project:', createError);
|
||||
error.value = 'Failed to create project.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||
async ([isAuthenticated, workspaceId]) => {
|
||||
if (!isAuthenticated || !workspaceId) {
|
||||
projects.value = [];
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchProjects();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
projects,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
fetchProjects,
|
||||
createProject,
|
||||
};
|
||||
});
|
||||
232
frontend/src/features/projects/views/ProjectDetailView.vue
Normal file
232
frontend/src/features/projects/views/ProjectDetailView.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
|
||||
const project = computed(() =>
|
||||
projectsStore.projects.find(candidate => candidate.id === route.params.projectId) ?? null
|
||||
);
|
||||
|
||||
const scopedItems = computed(() =>
|
||||
contentItemsStore.items
|
||||
.filter(item => item.projectId === route.params.projectId)
|
||||
.sort((left, right) => {
|
||||
const leftDue = left.dueDate ? new Date(left.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const rightDue = right.dueDate ? new Date(right.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return leftDue - rightDue;
|
||||
})
|
||||
);
|
||||
|
||||
function formatProjectDateRange(projectValue) {
|
||||
if (!projectValue?.startDate || !projectValue?.endDate) {
|
||||
return 'No date range';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(new Date(projectValue.startDate), new Date(projectValue.endDate));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div
|
||||
v-if="!project"
|
||||
class="page-message error"
|
||||
>
|
||||
The selected campaign could not be found in the active workspace.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="hero">
|
||||
<div>
|
||||
<div class="breadcrumb-row">
|
||||
<router-link
|
||||
class="breadcrumb"
|
||||
:to="{ name: 'workspace-dashboard' }"
|
||||
>
|
||||
Workspace
|
||||
</router-link>
|
||||
<span>/</span>
|
||||
<router-link
|
||||
class="breadcrumb"
|
||||
:to="{ name: 'campaigns' }"
|
||||
>
|
||||
Campaigns
|
||||
</router-link>
|
||||
</div>
|
||||
<h1>{{ project.name }}</h1>
|
||||
<p>{{ project.description || `${workspaceStore.activeWorkspace?.name} delivery stream with only the content scheduled in this campaign.` }}</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<div class="meta-chip">{{ project.status }}</div>
|
||||
<div class="meta-copy">{{ formatProjectDateRange(project) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="project.notes"
|
||||
class="page-message"
|
||||
>
|
||||
{{ project.notes }}
|
||||
</div>
|
||||
|
||||
<div class="section-header">
|
||||
<strong>Content items</strong>
|
||||
<span>{{ scopedItems.length }} scheduled in this campaign</span>
|
||||
</div>
|
||||
|
||||
<div class="scope-actions">
|
||||
<router-link
|
||||
v-if="authStore.isManager || authStore.isProvider"
|
||||
:to="{ name: 'content-item-create', query: { projectId: project.id } }"
|
||||
class="scope-button"
|
||||
>
|
||||
New content in {{ project.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scopedItems.length"
|
||||
class="content-grid"
|
||||
>
|
||||
<router-link
|
||||
v-for="item in scopedItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'content-item-detail', params: { id: item.id } }"
|
||||
class="content-card"
|
||||
>
|
||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.publicationTargets }}</span>
|
||||
<div class="status-row">
|
||||
<em>{{ item.status }}</em>
|
||||
<small>{{ item.dueDate ? new Date(item.dueDate).toLocaleDateString() : 'No due date' }}</small>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="page-message"
|
||||
>
|
||||
No content items are attached to this campaign yet.
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.content-card {
|
||||
@apply rounded-[1.5rem] border;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply flex flex-col gap-5 p-6 lg:flex-row lg:items-start lg:justify-between;
|
||||
}
|
||||
|
||||
.breadcrumb-row {
|
||||
@apply flex items-center gap-2 text-sm;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.breadcrumb,
|
||||
.hero p,
|
||||
.meta-copy,
|
||||
.section-header span,
|
||||
.content-card span,
|
||||
.status-row small,
|
||||
.status-row em {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
@apply font-bold uppercase tracking-[0.16em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.section-header strong,
|
||||
.content-card strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
@apply flex flex-wrap items-start gap-3;
|
||||
}
|
||||
|
||||
.meta-chip,
|
||||
.version-chip {
|
||||
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.scope-actions {
|
||||
@apply flex justify-start;
|
||||
}
|
||||
|
||||
.scope-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.scope-button:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.section-header strong {
|
||||
@apply text-lg font-black;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
@apply flex flex-col gap-4 p-5 no-underline;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
376
frontend/src/features/projects/views/ProjectsView.vue
Normal file
376
frontend/src/features/projects/views/ProjectsView.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const clientsStore = useClientsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const { t } = useI18n();
|
||||
const isCreateFormVisible = ref(false);
|
||||
const formError = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
description: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const operationalClient = computed(() => clientsStore.operationalClient);
|
||||
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.startDate = '';
|
||||
form.endDate = '';
|
||||
form.description = '';
|
||||
form.notes = '';
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
resetForm();
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (projectsStore.isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
if (!form.name || !form.startDate || !form.endDate) {
|
||||
formError.value = t('projects.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(form.endDate) < new Date(form.startDate)) {
|
||||
formError.value = t('projects.errors.invalidDateRange');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operationalClient.value?.id) {
|
||||
formError.value = t('projects.errors.workspaceAccountRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await projectsStore.createProject({
|
||||
clientId: operationalClient.value.id,
|
||||
name: form.name,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: new Date(form.endDate).toISOString(),
|
||||
description: form.description,
|
||||
notes: form.notes,
|
||||
});
|
||||
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
formError.value = t('projects.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.create,
|
||||
createValue => {
|
||||
if (createValue === 'true') {
|
||||
openCreateForm();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function formatProjectDateRange(project) {
|
||||
if (!project?.startDate || !project?.endDate) {
|
||||
return t('projects.noDateRange');
|
||||
}
|
||||
|
||||
const start = new Date(project.startDate);
|
||||
const end = new Date(project.endDate);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(start, end);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('projects.eyebrow') }}</div>
|
||||
<h1>{{ t('projects.title') }}</h1>
|
||||
<p>{{ t('projects.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button
|
||||
v-if="authStore.isManager"
|
||||
class="create-button"
|
||||
@click="openCreateForm"
|
||||
>
|
||||
{{ t('projects.newProject') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFormVisible"
|
||||
class="create-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('projects.createTitle') }}</strong>
|
||||
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>{{ t('projects.fields.startDate') }}</span>
|
||||
<input
|
||||
v-model="form.startDate"
|
||||
type="date"
|
||||
:disabled="projectsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('projects.fields.endDate') }}</span>
|
||||
<input
|
||||
v-model="form.endDate"
|
||||
type="date"
|
||||
:disabled="projectsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('projects.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:disabled="projectsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('projects.fields.description') }}</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
:disabled="projectsStore.isCreating"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('projects.fields.notes') }}</span>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
:disabled="projectsStore.isCreating"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="projectsStore.isCreating"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="projectsStore.isCreating"
|
||||
@click="submitForm"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="projectsStore.isCreating"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ projectsStore.isCreating ? t('common.creating') : t('projects.createTitle') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectsStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('projects.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="projectsStore.error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ projectsStore.error }}
|
||||
</div>
|
||||
|
||||
<div class="project-stack">
|
||||
<router-link
|
||||
v-for="project in projectsStore.projects"
|
||||
:key="project.id"
|
||||
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
|
||||
class="project-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
<span>{{ project.description || project.status }}</span>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
|
||||
<em>{{ formatProjectDateRange(project) }}</em>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!projectsStore.isLoading && !projectsStore.projects.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('projects.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p,
|
||||
.panel-header span,
|
||||
.project-row span,
|
||||
.project-meta span,
|
||||
.project-meta em {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@apply flex justify-end;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.create-panel,
|
||||
.project-row {
|
||||
@apply rounded-[1.5rem] border;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
@apply flex flex-col gap-5 p-5;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.panel-header strong,
|
||||
.project-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
@apply min-h-28 rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #172033;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex justify-end gap-3;
|
||||
}
|
||||
|
||||
.project-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
|
||||
}
|
||||
|
||||
.project-row strong {
|
||||
@apply block text-xl font-black;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
@apply flex flex-col items-start gap-1 lg:items-end;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
49
frontend/src/features/reviews/stores/reviewQueueStore.js
Normal file
49
frontend/src/features/reviews/stores/reviewQueueStore.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
|
||||
const stageByStatus = {
|
||||
Draft: 'Draft',
|
||||
'In internal review': 'Internal review',
|
||||
'Changes requested internally': 'Internal changes requested',
|
||||
'Internal changes in progress': 'Internal revision',
|
||||
'Ready for client review': 'Ready for client review',
|
||||
'In client review': 'Client review',
|
||||
'Changes requested by client': 'Client changes requested',
|
||||
'Client changes in progress': 'Client revision',
|
||||
Approved: 'Approved',
|
||||
Rejected: 'Rejected',
|
||||
'Ready to publish': 'Ready to publish',
|
||||
Published: 'Published',
|
||||
Archived: 'Archived',
|
||||
};
|
||||
|
||||
export const useReviewQueueStore = defineStore('review-queue', () => {
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const items = computed(() =>
|
||||
contentItemsStore.items
|
||||
.filter(item => item.status !== 'Draft' && item.status !== 'Published' && item.status !== 'Archived')
|
||||
.map(item => {
|
||||
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
projectName: project?.name ?? 'Unknown campaign',
|
||||
stage: stageByStatus[item.status] ?? item.status,
|
||||
status: item.status,
|
||||
dueLabel: item.dueDate ? `Due ${new Date(item.dueDate).toLocaleDateString()}` : 'No due date',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const urgentItems = computed(() => items.value.slice(0, 5));
|
||||
|
||||
return {
|
||||
items,
|
||||
urgentItems,
|
||||
};
|
||||
});
|
||||
102
frontend/src/features/reviews/views/ReviewQueueView.vue
Normal file
102
frontend/src/features/reviews/views/ReviewQueueView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useReviewQueueStore } from '@/features/reviews/stores/reviewQueueStore.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const reviewQueueStore = useReviewQueueStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('reviewQueue.eyebrow') }}</div>
|
||||
<h1>{{ t('reviewQueue.title') }}</h1>
|
||||
<p>{{ t('reviewQueue.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-list">
|
||||
<article
|
||||
v-for="item in reviewQueueStore.items"
|
||||
:key="item.id"
|
||||
class="queue-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.projectName }} · {{ item.stage }}</span>
|
||||
</div>
|
||||
<div class="queue-meta">
|
||||
<em>{{ item.status }}</em>
|
||||
<small>{{ item.dueLabel }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!reviewQueueStore.items.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('reviewQueue.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p {
|
||||
@apply mt-3 max-w-2xl text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.queue-row {
|
||||
@apply flex flex-col justify-between gap-4 rounded-[1.5rem] border p-5 lg:flex-row lg:items-center;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.queue-row strong {
|
||||
@apply block text-xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.queue-row span,
|
||||
.queue-meta span,
|
||||
.queue-meta small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.queue-meta {
|
||||
@apply flex flex-col items-start gap-1 lg:items-end;
|
||||
}
|
||||
|
||||
.queue-meta em {
|
||||
@apply text-sm font-semibold uppercase tracking-[0.16em] not-italic;
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="eyebrow">{{ t('integrations.eyebrow') }}</div>
|
||||
<h1>{{ t('integrations.title') }}</h1>
|
||||
<p>{{ t('integrations.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('integrations.googleDrive.title') }}</strong>
|
||||
<span>{{ t('integrations.googleDrive.description') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-block">
|
||||
<span>{{ t('integrations.statusLabel') }}</span>
|
||||
<strong>{{ t('integrations.pendingTitle') }}</strong>
|
||||
<small>{{ t('integrations.googleDrive.nextStep') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('integrations.apiKeys.title') }}</strong>
|
||||
<span>{{ t('integrations.apiKeys.description') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-block">
|
||||
<span>{{ t('integrations.statusLabel') }}</span>
|
||||
<strong>{{ t('integrations.pendingTitle') }}</strong>
|
||||
<small>{{ t('integrations.apiKeys.nextStep') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.panel-heading span,
|
||||
.placeholder-block span,
|
||||
.placeholder-block small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.panel-heading strong,
|
||||
.placeholder-block strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-heading strong {
|
||||
@apply text-lg font-black;
|
||||
}
|
||||
|
||||
.placeholder-block {
|
||||
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.placeholder-block strong {
|
||||
@apply text-xl font-black;
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/features/settings/views/SettingsLayoutView.vue
Normal file
93
frontend/src/features/settings/views/SettingsLayoutView.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-shell">
|
||||
<aside class="settings-nav">
|
||||
<div class="settings-nav-header">
|
||||
<div class="eyebrow">{{ t('settings.eyebrow') }}</div>
|
||||
<h1>{{ t('settings.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'settings-user-information' }"
|
||||
class="settings-link"
|
||||
>
|
||||
{{ t('settings.userInformation') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="authStore.isManager"
|
||||
:to="{ name: 'settings-workspaces' }"
|
||||
class="settings-link"
|
||||
>
|
||||
{{ t('settings.workspaces') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="authStore.isManager"
|
||||
:to="{ name: 'settings-integrations' }"
|
||||
class="settings-link"
|
||||
>
|
||||
{{ t('settings.integrations') }}
|
||||
</router-link>
|
||||
</aside>
|
||||
|
||||
<div class="settings-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-shell {
|
||||
@apply mx-auto grid w-full max-w-7xl gap-4 px-5 py-8 md:px-8 xl:grid-cols-[16rem_minmax(0,1fr)];
|
||||
}
|
||||
|
||||
.settings-nav,
|
||||
.settings-content :deep(.page-shell) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@apply flex h-fit flex-col gap-2 rounded-[1.75rem] border p-4;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.settings-nav-header {
|
||||
@apply mb-2 px-2;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.settings-nav-header h1 {
|
||||
@apply mt-2 text-2xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-link {
|
||||
@apply rounded-[1rem] px-4 py-3 text-sm font-semibold no-underline transition;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.settings-link:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings-link.router-link-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
192
frontend/src/features/user-profile/stores/userProfileStore.js
Normal file
192
frontend/src/features/user-profile/stores/userProfileStore.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import {computed, watch} from 'vue'
|
||||
import {defineStore} from 'pinia'
|
||||
import {useAuthStore} from "@/features/auth/stores/authStore.js";
|
||||
import {useClient} from "@/plugins/api.js";
|
||||
import {useSessionStorage} from "@vueuse/core";
|
||||
|
||||
export const useUserProfileStore = defineStore(
|
||||
'user-profile',
|
||||
() => {
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const authWatcher = watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchCurrentUserProfile()
|
||||
} else if (!authStore.isRefreshing) {
|
||||
value.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const value = useSessionStorage(
|
||||
'user-profile',
|
||||
{},
|
||||
{writeDefaults: false})
|
||||
|
||||
const fullname = computed(() => {
|
||||
if (value.value) {
|
||||
const {firstname, lastname} = value.value;
|
||||
|
||||
if (firstname && lastname) {
|
||||
return `${lastname}, ${firstname}`;
|
||||
} else if (firstname) {
|
||||
return firstname;
|
||||
} else if (lastname) {
|
||||
return lastname;
|
||||
}
|
||||
}
|
||||
return 'n/a';
|
||||
})
|
||||
|
||||
const alias = computed(() => {
|
||||
if (value.value) {
|
||||
return value.value.alias || `${value.value.firstname || ''} ${value.value.lastname || ''}`.trim() || 'Anonyme'
|
||||
}
|
||||
return 'Anonyme';
|
||||
})
|
||||
|
||||
const portraitUrl = computed(() => {
|
||||
return value.value && value.value.portraitUrl
|
||||
? value.value.portraitUrl
|
||||
: null
|
||||
})
|
||||
|
||||
const roles = computed(() => value.value?.userRoles ?? [])
|
||||
const persona = computed(() => value.value?.persona ?? null)
|
||||
const authorizedWorkspaceIds = computed(() => value.value?.authorizedWorkspaceIds ?? [])
|
||||
const authorizedClientIds = computed(() => value.value?.authorizedClientIds ?? [])
|
||||
const authorizedProjectIds = computed(() => value.value?.authorizedProjectIds ?? [])
|
||||
|
||||
async function fetchCurrentUserProfile() {
|
||||
try {
|
||||
const client = useClient()
|
||||
const userResponse = await client.get("/api/users/profile");
|
||||
value.value = userResponse.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFullname(firstname, lastname) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/fullname`,
|
||||
{
|
||||
firstname: firstname,
|
||||
lastname: lastname
|
||||
})
|
||||
value.value.firstname = firstname;
|
||||
value.value.lastname = lastname;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAlias(alias) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/alias`,
|
||||
{
|
||||
alias: alias
|
||||
})
|
||||
value.value.alias = alias;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeBirthday(birthdate) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/birthdate`,
|
||||
{
|
||||
birthdate: birthdate
|
||||
})
|
||||
value.value.birthDate = birthdate;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changePhone(phoneNumber) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/phone`,
|
||||
{
|
||||
phoneNumber: phoneNumber
|
||||
})
|
||||
value.value.phoneNumber = phoneNumber;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeEmail(email) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/email`,
|
||||
{
|
||||
email: email
|
||||
})
|
||||
value.value.email = email;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAddress(address) {
|
||||
try {
|
||||
const client = useClient()
|
||||
await client.post(
|
||||
`/api/users/address`,
|
||||
{
|
||||
address: address
|
||||
})
|
||||
value.value.address = address;
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function changePortrait(selectedFile) {
|
||||
try {
|
||||
const client = useClient()
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile, selectedFile.name || 'portrait.png')
|
||||
|
||||
const response = await client.post(
|
||||
`/api/users/portrait`,
|
||||
formData)
|
||||
|
||||
value.value.portraitUrl = `${response.data.blobUrl}?${Date.now()}` // the Date.now() is for cache-busting
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: value,
|
||||
alias,
|
||||
fullname,
|
||||
portraitUrl,
|
||||
roles,
|
||||
persona,
|
||||
authorizedWorkspaceIds,
|
||||
authorizedClientIds,
|
||||
authorizedProjectIds,
|
||||
changeFullname,
|
||||
changeAlias,
|
||||
changeBirthday,
|
||||
changePhone,
|
||||
changeEmail,
|
||||
changeAddress,
|
||||
changePortrait
|
||||
}
|
||||
})
|
||||
163
frontend/src/features/user-profile/views/UserSettingsView.vue
Normal file
163
frontend/src/features/user-profile/views/UserSettingsView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const { t } = useI18n();
|
||||
const isPortraitDialogOpen = ref(false);
|
||||
const isSavingPortrait = ref(false);
|
||||
|
||||
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
|
||||
const alias = computed(() => userProfileStore.alias);
|
||||
const fullname = computed(() => userProfileStore.fullname);
|
||||
|
||||
async function savePortrait(result) {
|
||||
isSavingPortrait.value = true;
|
||||
|
||||
try {
|
||||
await userProfileStore.changePortrait(result.file);
|
||||
isPortraitDialogOpen.value = false;
|
||||
} finally {
|
||||
isSavingPortrait.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="eyebrow">{{ t('userSettings.eyebrow') }}</div>
|
||||
<h1>{{ t('userSettings.title') }}</h1>
|
||||
<p>{{ t('userSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel hero-panel">
|
||||
<div class="hero-identity">
|
||||
<AppAvatar
|
||||
:name="alias"
|
||||
:src="userProfileStore.portraitUrl"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ alias }}</strong>
|
||||
<span>{{ fullname }}</span>
|
||||
<small>{{ email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
@click="isPortraitDialogOpen = true"
|
||||
>
|
||||
{{ t('userSettings.updatePortrait') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ t('userSettings.accountDetails') }}</strong>
|
||||
<span>{{ t('userSettings.accountDetailsDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.alias') }}</span>
|
||||
<strong>{{ alias }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.fullName') }}</span>
|
||||
<strong>{{ fullname }}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>{{ t('userSettings.email') }}</span>
|
||||
<strong>{{ email }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isPortraitDialogOpen"
|
||||
:title="t('userSettings.cropperTitle')"
|
||||
:confirm-label="t('userSettings.savePortrait')"
|
||||
:upload-label="t('userSettings.choosePortrait')"
|
||||
:is-saving="isSavingPortrait"
|
||||
@save="savePortrait"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.panel-heading span,
|
||||
.hero-identity span,
|
||||
.hero-identity small,
|
||||
.detail-row span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
@apply flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between;
|
||||
}
|
||||
|
||||
.hero-identity {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.hero-identity strong,
|
||||
.panel-heading strong,
|
||||
.detail-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-identity strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.panel-heading strong {
|
||||
@apply text-lg font-black;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@apply flex flex-col gap-1 rounded-[1.25rem] border p-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
</style>
|
||||
208
frontend/src/features/workspaces/stores/workspaceStore.js
Normal file
208
frontend/src/features/workspaces/stores/workspaceStore.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const authStore = useAuthStore();
|
||||
const client = useClient();
|
||||
|
||||
const workspaces = ref([]);
|
||||
const activeWorkspaceId = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const invitesByWorkspace = ref({});
|
||||
const membersByWorkspace = ref({});
|
||||
const isInvitesLoading = ref(false);
|
||||
const isMembersLoading = ref(false);
|
||||
const isInviting = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const activeWorkspace = computed(() =>
|
||||
workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null
|
||||
);
|
||||
|
||||
async function fetchWorkspaces() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/workspaces');
|
||||
workspaces.value = response.data ?? [];
|
||||
|
||||
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
|
||||
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspaces:', fetchError);
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = 'Failed to load workspaces.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkspace(payload) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
throw new Error('You must be authenticated to create a workspace.');
|
||||
}
|
||||
|
||||
if (isCreating.value) {
|
||||
throw new Error('A workspace creation request is already in progress.');
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/workspaces', payload);
|
||||
|
||||
if (response.data) {
|
||||
workspaces.value = [...workspaces.value, response.data]
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
activeWorkspaceId.value = response.data.id;
|
||||
|
||||
try {
|
||||
await client.post('/api/clients', {
|
||||
workspaceId: response.data.id,
|
||||
name: response.data.name,
|
||||
});
|
||||
} catch (hiddenClientError) {
|
||||
console.error('Failed to provision operational client for workspace:', hiddenClientError);
|
||||
}
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (createError) {
|
||||
console.error('Failed to create workspace:', createError);
|
||||
error.value = 'Failed to create workspace.';
|
||||
throw createError;
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveWorkspace(workspaceId) {
|
||||
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
||||
activeWorkspaceId.value = workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvites(workspaceId = activeWorkspaceId.value) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
invitesByWorkspace.value = {};
|
||||
return [];
|
||||
}
|
||||
|
||||
isInvitesLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/workspaces/${workspaceId}/invites`);
|
||||
invitesByWorkspace.value = {
|
||||
...invitesByWorkspace.value,
|
||||
[workspaceId]: response.data ?? [],
|
||||
};
|
||||
|
||||
return invitesByWorkspace.value[workspaceId];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspace invites:', fetchError);
|
||||
throw fetchError;
|
||||
} finally {
|
||||
isInvitesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMembers(workspaceId = activeWorkspaceId.value) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
membersByWorkspace.value = {};
|
||||
return [];
|
||||
}
|
||||
|
||||
isMembersLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/workspaces/${workspaceId}/members`);
|
||||
membersByWorkspace.value = {
|
||||
...membersByWorkspace.value,
|
||||
[workspaceId]: response.data ?? [],
|
||||
};
|
||||
|
||||
return membersByWorkspace.value[workspaceId];
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch workspace members:', fetchError);
|
||||
throw fetchError;
|
||||
} finally {
|
||||
isMembersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteMember(payload) {
|
||||
if (!authStore.isAuthenticated || !activeWorkspaceId.value) {
|
||||
throw new Error('You must be authenticated to invite a workspace member.');
|
||||
}
|
||||
|
||||
if (isInviting.value) {
|
||||
throw new Error('A workspace invite request is already in progress.');
|
||||
}
|
||||
|
||||
isInviting.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/workspaces/${activeWorkspaceId.value}/invites`, payload);
|
||||
invitesByWorkspace.value = {
|
||||
...invitesByWorkspace.value,
|
||||
[activeWorkspaceId.value]: [response.data, ...(invitesByWorkspace.value[activeWorkspaceId.value] ?? [])],
|
||||
};
|
||||
|
||||
return response.data;
|
||||
} catch (inviteError) {
|
||||
console.error('Failed to create workspace invite:', inviteError);
|
||||
throw inviteError;
|
||||
} finally {
|
||||
isInviting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
workspaces.value = [];
|
||||
activeWorkspaceId.value = null;
|
||||
error.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchWorkspaces();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
activeWorkspace,
|
||||
isLoading,
|
||||
isCreating,
|
||||
invitesByWorkspace,
|
||||
membersByWorkspace,
|
||||
isInvitesLoading,
|
||||
isMembersLoading,
|
||||
isInviting,
|
||||
error,
|
||||
fetchWorkspaces,
|
||||
createWorkspace,
|
||||
fetchInvites,
|
||||
fetchMembers,
|
||||
inviteMember,
|
||||
setActiveWorkspace,
|
||||
};
|
||||
});
|
||||
620
frontend/src/features/workspaces/views/DashboardView.vue
Normal file
620
frontend/src/features/workspaces/views/DashboardView.vue
Normal file
@@ -0,0 +1,620 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const viewMode = ref('month');
|
||||
const cursorDate = ref(today);
|
||||
|
||||
const contentStatusMeta = {
|
||||
Draft: { tone: 'production', readiness: 'building' },
|
||||
'In internal review': { tone: 'approval', readiness: 'approval' },
|
||||
'Changes requested internally': { tone: 'risk', readiness: 'rework' },
|
||||
'Internal changes in progress': { tone: 'production', readiness: 'building' },
|
||||
'Ready for client review': { tone: 'approval', readiness: 'approval' },
|
||||
'In client review': { tone: 'approval', readiness: 'approval' },
|
||||
'Changes requested by client': { tone: 'risk', readiness: 'rework' },
|
||||
'Client changes in progress': { tone: 'production', readiness: 'building' },
|
||||
Approved: { tone: 'ready', readiness: 'ready' },
|
||||
'Ready to publish': { tone: 'ready', readiness: 'ready' },
|
||||
Published: { tone: 'published', readiness: 'published' },
|
||||
Rejected: { tone: 'risk', readiness: 'blocked' },
|
||||
Archived: { tone: 'muted', readiness: 'archived' },
|
||||
};
|
||||
|
||||
const contentItemsByProjectId = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const item of contentItemsStore.items) {
|
||||
const existing = grouped.get(item.projectId) ?? [];
|
||||
existing.push(item);
|
||||
grouped.set(item.projectId, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const calendarEntries = computed(() => {
|
||||
const projectEntries = projectsStore.projects
|
||||
.filter(project => project.endDate || project.startDate)
|
||||
.map(project => buildProjectEntry(project));
|
||||
|
||||
const contentEntries = contentItemsStore.items
|
||||
.filter(item => item.dueDate && item.status !== 'Archived')
|
||||
.map(item => buildContentEntry(item));
|
||||
|
||||
return [...projectEntries, ...contentEntries].sort(sortByDate);
|
||||
});
|
||||
|
||||
const entriesByDay = computed(() => {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const entry of calendarEntries.value) {
|
||||
const existing = grouped.get(entry.dayKey) ?? [];
|
||||
existing.push(entry);
|
||||
grouped.set(entry.dayKey, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const visibleDays = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = addDays(start, index);
|
||||
|
||||
return buildDay(date, false);
|
||||
});
|
||||
}
|
||||
|
||||
const start = startOfWeek(startOfMonth(cursorDate.value));
|
||||
const end = endOfWeek(endOfMonth(cursorDate.value));
|
||||
const days = [];
|
||||
let current = start;
|
||||
|
||||
while (current <= end) {
|
||||
days.push(buildDay(current, current.getMonth() !== cursorDate.value.getMonth()));
|
||||
current = addDays(current, 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
const weekdayLabels = computed(() => {
|
||||
const base = startOfWeek(cursorDate.value);
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) =>
|
||||
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(addDays(base, index))
|
||||
);
|
||||
});
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
if (viewMode.value === 'week') {
|
||||
const start = startOfWeek(cursorDate.value);
|
||||
const end = addDays(start, 6);
|
||||
const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
|
||||
|
||||
if (sameMonth) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(start, end);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).formatRange(start, end);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(cursorDate.value);
|
||||
});
|
||||
|
||||
const isLoading = computed(() =>
|
||||
workspaceStore.isLoading || projectsStore.isLoading || contentItemsStore.isLoading
|
||||
);
|
||||
|
||||
const pageError = computed(() =>
|
||||
workspaceStore.error || projectsStore.error || contentItemsStore.error
|
||||
);
|
||||
|
||||
function buildDay(date, isOutsideMonth) {
|
||||
const key = dateKey(date);
|
||||
|
||||
return {
|
||||
key,
|
||||
date,
|
||||
entries: entriesByDay.value.get(key) ?? [],
|
||||
isOutsideMonth,
|
||||
isToday: key === dateKey(today),
|
||||
};
|
||||
}
|
||||
|
||||
function buildContentEntry(item) {
|
||||
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production', readiness: 'building' };
|
||||
const project = projectsStore.projects.find(candidate => candidate.id === item.projectId);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: 'content',
|
||||
title: item.title,
|
||||
subtitle: project?.name ?? t('dashboard.labels.unassignedProject'),
|
||||
scheduledAt: new Date(item.dueDate),
|
||||
dayKey: dateKey(item.dueDate),
|
||||
timeLabel: formatHour(item.dueDate),
|
||||
tone: statusMeta.tone,
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectEntry(project) {
|
||||
const projectItems = contentItemsByProjectId.value.get(project.id) ?? [];
|
||||
const approvedCount = projectItems.filter(item => ['Approved', 'Ready to publish', 'Published'].includes(item.status)).length;
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
type: 'project',
|
||||
title: project.name,
|
||||
subtitle: projectItems.length
|
||||
? t('dashboard.projectProgress', { scheduled: projectItems.length, approved: approvedCount })
|
||||
: t('dashboard.readiness.missing'),
|
||||
scheduledAt: new Date(project.endDate ?? project.startDate),
|
||||
dayKey: dateKey(project.endDate ?? project.startDate),
|
||||
timeLabel: t('dashboard.campaignDeadline'),
|
||||
tone: projectItems.length ? 'project' : 'risk',
|
||||
route: { name: 'campaign-detail', params: { projectId: project.id } },
|
||||
};
|
||||
}
|
||||
|
||||
function setView(mode) {
|
||||
viewMode.value = mode;
|
||||
cursorDate.value = mode === 'month' ? startOfMonth(cursorDate.value) : startOfWeek(cursorDate.value);
|
||||
}
|
||||
|
||||
function shiftPeriod(direction) {
|
||||
cursorDate.value = viewMode.value === 'month'
|
||||
? addMonths(cursorDate.value, direction)
|
||||
: addDays(cursorDate.value, direction * 7);
|
||||
}
|
||||
|
||||
function jumpToToday() {
|
||||
cursorDate.value = today;
|
||||
}
|
||||
|
||||
function formatDayNumber(date) {
|
||||
return new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
function formatHour(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function startOfWeek(value) {
|
||||
const date = startOfDay(value);
|
||||
const day = date.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
return addDays(date, diff);
|
||||
}
|
||||
|
||||
function endOfWeek(value) {
|
||||
return addDays(startOfWeek(value), 6);
|
||||
}
|
||||
|
||||
function startOfMonth(value) {
|
||||
const date = startOfDay(value);
|
||||
date.setDate(1);
|
||||
return date;
|
||||
}
|
||||
|
||||
function endOfMonth(value) {
|
||||
const date = startOfMonth(value);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
date.setDate(0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addDays(value, amount) {
|
||||
const date = startOfDay(value);
|
||||
date.setDate(date.getDate() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addMonths(value, amount) {
|
||||
const date = startOfMonth(value);
|
||||
date.setMonth(date.getMonth() + amount);
|
||||
return date;
|
||||
}
|
||||
|
||||
function dateKey(value) {
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function sortByDate(left, right) {
|
||||
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="calendar-shell">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('dashboard.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-else
|
||||
class="calendar-card"
|
||||
>
|
||||
<div class="calendar-toolbar">
|
||||
<div class="calendar-nav">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="shiftPeriod(-1)"
|
||||
>
|
||||
<v-icon :icon="mdiChevronLeft" />
|
||||
</button>
|
||||
|
||||
<div class="calendar-period">{{ periodLabel }}</div>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
@click="shiftPeriod(1)"
|
||||
>
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-controls">
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="jumpToToday"
|
||||
>
|
||||
{{ t('today') }}
|
||||
</button>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'month' }"
|
||||
type="button"
|
||||
@click="setView('month')"
|
||||
>
|
||||
{{ t('dashboard.month') }}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-button"
|
||||
:class="{ 'toggle-button-active': viewMode === 'week' }"
|
||||
type="button"
|
||||
@click="setView('week')"
|
||||
>
|
||||
{{ t('dashboard.week') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid calendar-grid-head">
|
||||
<div
|
||||
v-for="label in weekdayLabels"
|
||||
:key="label"
|
||||
class="weekday-label"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="calendar-grid"
|
||||
:class="viewMode === 'week' ? 'calendar-grid-week' : 'calendar-grid-month'"
|
||||
>
|
||||
<div
|
||||
v-for="day in visibleDays"
|
||||
:key="day.key"
|
||||
class="calendar-day"
|
||||
:class="{
|
||||
'calendar-day-outside': day.isOutsideMonth,
|
||||
'calendar-day-today': day.isToday,
|
||||
'calendar-day-week': viewMode === 'week',
|
||||
}"
|
||||
>
|
||||
<div class="day-number">
|
||||
{{ formatDayNumber(day.date) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="day.entries.length"
|
||||
class="day-entries"
|
||||
>
|
||||
<router-link
|
||||
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
|
||||
:key="`${entry.type}-${entry.id}`"
|
||||
:to="entry.route"
|
||||
class="calendar-entry"
|
||||
:class="entry.tone"
|
||||
>
|
||||
<span class="entry-time">{{ entry.timeLabel }}</span>
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span>{{ entry.subtitle }}</span>
|
||||
</router-link>
|
||||
|
||||
<div
|
||||
v-if="viewMode === 'month' && day.entries.length > 3"
|
||||
class="entry-more"
|
||||
>
|
||||
{{ t('dashboard.moreItems', { count: day.entries.length - 3 }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="viewMode === 'week'"
|
||||
class="day-empty"
|
||||
>
|
||||
{{ t('dashboard.emptyPeriod') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-shell {
|
||||
@apply mx-auto w-full max-w-7xl px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
@apply rounded-[1.75rem] border p-4 md:p-5;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
@apply mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between;
|
||||
}
|
||||
|
||||
.calendar-nav,
|
||||
.calendar-controls {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.calendar-controls {
|
||||
@apply flex-wrap justify-end;
|
||||
}
|
||||
|
||||
.calendar-period {
|
||||
@apply min-w-0 px-2 text-base font-bold md:text-lg;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.text-button,
|
||||
.toggle-button {
|
||||
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@apply h-10 w-10 px-0 py-0;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.text-button:hover,
|
||||
.toggle-button:hover {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
@apply inline-flex rounded-full border p-1;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.toggle-button-active {
|
||||
background: #172033;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
@apply grid gap-3;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-grid-head {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.weekday-label {
|
||||
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
|
||||
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.calendar-day-week {
|
||||
@apply min-h-[22rem];
|
||||
}
|
||||
|
||||
.calendar-day-outside {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.calendar-day-today {
|
||||
border-color: rgba(15, 118, 110, 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.day-number {
|
||||
@apply mb-3 text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.day-entries {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
|
||||
}
|
||||
|
||||
.calendar-entry:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.calendar-entry strong {
|
||||
@apply text-sm font-bold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.calendar-entry span {
|
||||
@apply text-xs leading-5;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.entry-more,
|
||||
.day-empty {
|
||||
@apply px-1 text-xs font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.calendar-entry.production {
|
||||
background: #fff7ed;
|
||||
border-color: rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
|
||||
.calendar-entry.approval {
|
||||
background: #eff6ff;
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.ready {
|
||||
background: #ecfdf5;
|
||||
border-color: rgba(5, 150, 105, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.risk {
|
||||
background: #fef2f2;
|
||||
border-color: rgba(220, 38, 38, 0.16);
|
||||
}
|
||||
|
||||
.calendar-entry.project {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(71, 85, 105, 0.18);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.calendar-entry.published,
|
||||
.calendar-entry.muted {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-shell {
|
||||
@apply px-4 py-6;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weekday-label {
|
||||
@apply text-[0.65rem];
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
@apply min-h-[7rem] p-2;
|
||||
}
|
||||
|
||||
.calendar-day-week {
|
||||
@apply min-h-[18rem];
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.calendar-toolbar {
|
||||
@apply items-stretch;
|
||||
}
|
||||
|
||||
.calendar-nav,
|
||||
.calendar-controls {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.calendar-grid-head,
|
||||
.calendar-grid {
|
||||
min-width: 46rem;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
418
frontend/src/features/workspaces/views/OverviewView.vue
Normal file
418
frontend/src/features/workspaces/views/OverviewView.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const client = useClient();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const projects = ref([]);
|
||||
const contentItems = ref([]);
|
||||
const notifications = ref([]);
|
||||
|
||||
const workspaceMap = computed(() =>
|
||||
new Map(workspaceStore.workspaces.map(workspace => [workspace.id, workspace]))
|
||||
);
|
||||
|
||||
const workspaceStats = computed(() =>
|
||||
workspaceStore.workspaces.map(workspace => {
|
||||
const workspaceProjects = projects.value.filter(project => project.workspaceId === workspace.id);
|
||||
const workspaceContent = contentItems.value.filter(item => item.workspaceId === workspace.id);
|
||||
const upcomingCount = workspaceContent.filter(item => {
|
||||
if (!item.dueDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return startOfDay(item.dueDate) >= today.value;
|
||||
}).length;
|
||||
|
||||
const blockingCount = workspaceContent.filter(item =>
|
||||
['In internal review', 'Ready for client review', 'In client review', 'Changes requested by client'].includes(item.status)
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
timeZone: workspace.timeZone,
|
||||
projectCount: workspaceProjects.length,
|
||||
contentCount: workspaceContent.length,
|
||||
upcomingCount,
|
||||
blockingCount,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const today = computed(() => startOfDay(new Date()));
|
||||
|
||||
const upcomingEvents = computed(() =>
|
||||
contentItems.value
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: startOfDay(item.dueDate),
|
||||
status: item.status,
|
||||
workspaceId: item.workspaceId,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
}))
|
||||
.filter(item => item.date >= today.value)
|
||||
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
||||
.slice(0, 10)
|
||||
);
|
||||
|
||||
const crossWorkspaceRisks = computed(() =>
|
||||
contentItems.value
|
||||
.filter(item => item.dueDate)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: startOfDay(item.dueDate),
|
||||
status: item.status,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
}))
|
||||
.filter(item =>
|
||||
item.date < today.value && !['Approved', 'Ready to publish', 'Published', 'Archived'].includes(item.status)
|
||||
)
|
||||
.sort((left, right) => left.date.getTime() - right.date.getTime())
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
const activityFeed = computed(() =>
|
||||
notifications.value
|
||||
.map(item => ({
|
||||
...item,
|
||||
workspaceName: workspaceMap.value.get(item.workspaceId)?.name ?? t('nav.noWorkspace'),
|
||||
}))
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
const overviewStats = computed(() => [
|
||||
{ label: t('overview.stats.workspaces'), value: workspaceStore.workspaces.length },
|
||||
{ label: t('overview.stats.projects'), value: projects.value.length },
|
||||
{ label: t('overview.stats.upcoming'), value: upcomingEvents.value.length },
|
||||
{ label: t('overview.stats.blockers'), value: crossWorkspaceRisks.value.length },
|
||||
]);
|
||||
|
||||
async function loadOverview() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const [projectsResponse, contentItemsResponse, notificationsResponse] = await Promise.all([
|
||||
client.get('/api/projects'),
|
||||
client.get('/api/content-items'),
|
||||
client.get('/api/notifications'),
|
||||
]);
|
||||
|
||||
projects.value = projectsResponse.data ?? [];
|
||||
contentItems.value = contentItemsResponse.data ?? [];
|
||||
notifications.value = notificationsResponse.data ?? [];
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load cross-workspace overview:', loadError);
|
||||
error.value = 'Failed to load overview data.';
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
const date = new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async isAuthenticated => {
|
||||
if (isAuthenticated) {
|
||||
await loadOverview();
|
||||
} else {
|
||||
projects.value = [];
|
||||
contentItems.value = [];
|
||||
notifications.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.workspaces.length,
|
||||
async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await loadOverview();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await loadOverview();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('overview.eyebrow') }}</div>
|
||||
<h1>{{ t('overview.title') }}</h1>
|
||||
<p>{{ t('overview.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('overview.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="stats-grid">
|
||||
<article
|
||||
v-for="stat in overviewStats"
|
||||
:key="stat.label"
|
||||
class="stat-card"
|
||||
>
|
||||
<span>{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.workspacesKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.workspaceRollup') }}</div>
|
||||
<div class="workspace-stack">
|
||||
<button
|
||||
v-for="workspace in workspaceStats"
|
||||
:key="workspace.id"
|
||||
class="workspace-row"
|
||||
type="button"
|
||||
@click="workspaceStore.setActiveWorkspace(workspace.id)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ workspace.name }}</strong>
|
||||
<span>{{ workspace.timeZone }}</span>
|
||||
</div>
|
||||
<div class="workspace-meta">
|
||||
<small>{{ workspace.projectCount }} {{ t('overview.labels.projects') }}</small>
|
||||
<small>{{ workspace.upcomingCount }} {{ t('overview.labels.upcoming') }}</small>
|
||||
<small>{{ workspace.blockingCount }} {{ t('overview.labels.blocked') }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.timelineKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.upcomingTitle') }}</div>
|
||||
<router-link
|
||||
v-for="item in upcomingEvents"
|
||||
:key="item.id"
|
||||
:to="item.route"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.workspaceName }} · {{ item.status }}</span>
|
||||
</div>
|
||||
<em>{{ formatDate(item.date) }}</em>
|
||||
</router-link>
|
||||
<div
|
||||
v-if="!upcomingEvents.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyUpcoming') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.riskKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.risksTitle') }}</div>
|
||||
<router-link
|
||||
v-for="item in crossWorkspaceRisks"
|
||||
:key="item.id"
|
||||
:to="item.route"
|
||||
class="list-row alert"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.workspaceName }} · {{ item.status }}</span>
|
||||
</div>
|
||||
<em>{{ formatDate(item.date) }}</em>
|
||||
</router-link>
|
||||
<div
|
||||
v-if="!crossWorkspaceRisks.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyRisks') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-kicker">{{ t('overview.activityKicker') }}</div>
|
||||
<div class="panel-title">{{ t('overview.activityTitle') }}</div>
|
||||
<div
|
||||
v-for="item in activityFeed"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.workspaceName }}</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
<em>{{ formatDateTime(item.createdAt) }}</em>
|
||||
</div>
|
||||
<div
|
||||
v-if="!activityFeed.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('overview.emptyActivity') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.stat-card span,
|
||||
.list-row span,
|
||||
.workspace-row span,
|
||||
.empty-state {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
@apply grid gap-4 xl:grid-cols-2;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.panel {
|
||||
@apply rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.workspace-row strong,
|
||||
.list-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
@apply mt-3 block text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.workspace-stack {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.workspace-row,
|
||||
.list-row {
|
||||
@apply flex items-start justify-between gap-4 rounded-[1.1rem] border p-4 text-left no-underline;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.workspace-row.alert,
|
||||
.list-row.alert {
|
||||
background: #fff1f2;
|
||||
border-color: rgba(225, 29, 72, 0.14);
|
||||
}
|
||||
|
||||
.workspace-meta {
|
||||
@apply flex flex-col items-end gap-1;
|
||||
}
|
||||
|
||||
.workspace-meta small,
|
||||
.list-row em {
|
||||
@apply text-sm font-semibold not-italic;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-message,
|
||||
.empty-state {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
271
frontend/src/features/workspaces/views/WorkspaceCreateView.vue
Normal file
271
frontend/src/features/workspaces/views/WorkspaceCreateView.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
timeZone: computedDefaultTimeZone(),
|
||||
});
|
||||
const formError = ref(null);
|
||||
|
||||
const previewSlug = computed(() => {
|
||||
if (form.slug.trim()) {
|
||||
return slugify(form.slug);
|
||||
}
|
||||
|
||||
return slugify(form.name);
|
||||
});
|
||||
|
||||
function computedDefaultTimeZone() {
|
||||
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (workspaceStore.isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
const name = form.name.trim();
|
||||
const slug = slugify(form.slug || form.name);
|
||||
const timeZone = form.timeZone.trim();
|
||||
|
||||
if (!name || !slug || !timeZone) {
|
||||
formError.value = t('workspaceCreate.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.createWorkspace({
|
||||
name,
|
||||
slug,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
await router.push({ name: 'workspace-settings' });
|
||||
} catch (error) {
|
||||
formError.value = t('workspaceCreate.errors.createFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
await router.push({ name: 'workspace-settings' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">{{ t('workspaceCreate.eyebrow') }}</div>
|
||||
<h1>{{ t('workspaceCreate.title') }}</h1>
|
||||
<p>{{ t('workspaceCreate.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-note">
|
||||
<strong>{{ t('workspaceCreate.previewTitle') }}</strong>
|
||||
<span>{{ t('workspaceCreate.previewDescription') }}</span>
|
||||
<code>{{ previewSlug || 'workspace-slug' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="create-card">
|
||||
<div class="card-header">
|
||||
<strong>{{ t('workspaceCreate.formTitle') }}</strong>
|
||||
<span>{{ t('workspaceCreate.formDescription') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-grid"
|
||||
@submit.prevent="submitForm"
|
||||
>
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('workspaceCreate.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:placeholder="t('workspaceCreate.fields.namePlaceholder')"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceCreate.fields.slug') }}</span>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
type="text"
|
||||
:placeholder="t('workspaceCreate.fields.slugPlaceholder')"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
<small>{{ t('workspaceCreate.slugHint', { slug: previewSlug || 'workspace-slug' }) }}</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
|
||||
<input
|
||||
v-model="form.timeZone"
|
||||
type="text"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="panel-actions field-wide">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
type="submit"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
>
|
||||
{{ workspaceStore.isCreating ? t('common.creating') : t('workspaceCreate.createAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@apply grid gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(18rem,0.8fr)];
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.hero-note,
|
||||
.create-card {
|
||||
@apply rounded-[1.75rem] border;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
@apply p-6 md:p-8;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 138, 61, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 247, 237, 0.92));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
@apply mt-3 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.hero-note span,
|
||||
.card-header span,
|
||||
.field small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.hero-note,
|
||||
.create-card {
|
||||
@apply flex flex-col gap-4 p-6;
|
||||
}
|
||||
|
||||
.hero-note strong,
|
||||
.card-header strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.hero-note strong {
|
||||
@apply text-xl font-black;
|
||||
}
|
||||
|
||||
.hero-note code {
|
||||
@apply rounded-[1rem] px-3 py-2 text-sm;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.card-header strong {
|
||||
@apply text-2xl font-black;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex flex-wrap justify-end gap-3 pt-2;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
</style>
|
||||
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiCogOutline,
|
||||
mdiFolderGoogleDrive,
|
||||
mdiImageMultipleOutline,
|
||||
mdiTuneVariant,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeTab = ref('general');
|
||||
|
||||
const inviteForm = reactive({
|
||||
email: '',
|
||||
role: 'workspaceMember',
|
||||
});
|
||||
|
||||
const pendingInvites = computed(() =>
|
||||
workspaceStore.invitesByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const workspaceMembers = computed(() =>
|
||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const settingsTabs = computed(() => [
|
||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||
]);
|
||||
const workflowSteps = computed(() => [
|
||||
{
|
||||
key: 'internal',
|
||||
title: t('workspaceSettings.approvals.steps.internal'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'client',
|
||||
title: t('workspaceSettings.approvals.steps.client'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: t('workspaceSettings.approvals.steps.publish'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.activeWorkspaceId,
|
||||
async workspaceId => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.fetchInvites(workspaceId);
|
||||
await workspaceStore.fetchMembers(workspaceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace people data:', error);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function submitInvite() {
|
||||
if (!inviteForm.email.trim() || !inviteForm.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.inviteMember({
|
||||
email: inviteForm.email.trim(),
|
||||
role: inviteForm.role,
|
||||
});
|
||||
|
||||
inviteForm.email = '';
|
||||
inviteForm.role = 'workspaceMember';
|
||||
} catch (error) {
|
||||
console.error('Failed to invite workspace member:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function translateRole(role) {
|
||||
if (!role) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
|
||||
return t(`workspaceSettings.roles.${normalizedRole}`, role);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-settings-shell">
|
||||
<div class="workspace-settings-hero">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.currentWorkspace') }}</span>
|
||||
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
|
||||
<p>{{ t('workspaceSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="tab-button"
|
||||
:class="{ 'tab-button-active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<v-icon :icon="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="summary-grid"
|
||||
>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
|
||||
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitInvite"
|
||||
>
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberEmail') }}</span>
|
||||
<input
|
||||
v-model="inviteForm.email"
|
||||
type="email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
|
||||
<select v-model="inviteForm.role">
|
||||
<option value="workspaceMember">{{ t('workspaceSettings.roles.workspaceMember') }}</option>
|
||||
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
|
||||
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
>
|
||||
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isInvitesLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pendingInvites.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="invite in pendingInvites"
|
||||
:key="invite.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ invite.email }}</strong>
|
||||
<span>{{ t(`workspaceSettings.roles.${invite.role}`) }}</span>
|
||||
</div>
|
||||
<small>{{ formatDate(invite.createdAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.inviteEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isMembersLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="workspaceMembers.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="member in workspaceMembers"
|
||||
:key="member.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
<span>{{ member.email }}</span>
|
||||
<span>{{ member.roles.map(translateRole).join(' · ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.members.activeEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.flowTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="workflow-rule-list">
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="workflow-steps">
|
||||
<div
|
||||
v-for="step in workflowSteps"
|
||||
:key="step.key"
|
||||
class="workflow-step"
|
||||
>
|
||||
<div class="workflow-step-icon">
|
||||
<v-icon :icon="mdiCheckCircleOutline" />
|
||||
</div>
|
||||
<div class="workflow-step-copy">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span>{{ step.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.connectors.title') }}</span>
|
||||
<p>{{ t('workspaceSettings.connectors.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="connector-list">
|
||||
<div class="connector-row">
|
||||
<div class="connector-main">
|
||||
<div class="connector-icon">
|
||||
<v-icon :icon="mdiFolderGoogleDrive" />
|
||||
</div>
|
||||
|
||||
<div class="connector-copy">
|
||||
<strong>{{ t('workspaceSettings.connectors.googleDrive.title') }}</strong>
|
||||
<span>{{ t('workspaceSettings.connectors.googleDrive.description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connector-status">
|
||||
{{ t('workspaceSettings.connectors.googleDrive.status') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'media-library' }"
|
||||
class="connector-link"
|
||||
>
|
||||
<v-icon :icon="mdiImageMultipleOutline" />
|
||||
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
|
||||
</router-link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-settings-shell {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.workspace-settings-hero {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.workspace-settings-grid {
|
||||
@apply grid gap-4 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.workflow-grid {
|
||||
@apply grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)];
|
||||
}
|
||||
|
||||
.workspace-settings-grid-single {
|
||||
@apply lg:grid-cols-1;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@apply inline-flex items-center gap-3 rounded-full px-4 py-3 text-sm font-semibold transition;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.tab-button-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.summary-grid dd,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
.workflow-rule strong,
|
||||
.workflow-step-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-copy h1 {
|
||||
@apply text-3xl font-black;
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.summary-grid dt,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
.connector-copy span,
|
||||
.connector-link span,
|
||||
.workflow-rule span,
|
||||
.workflow-step-copy span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@apply grid gap-4 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
.summary-grid div {
|
||||
@apply rounded-[1rem] border p-4;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.summary-grid dt {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
}
|
||||
|
||||
.summary-grid dd {
|
||||
@apply mt-2 text-base font-semibold;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.invite-list,
|
||||
.connector-list,
|
||||
.workflow-rule-list,
|
||||
.workflow-steps {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.invite-row,
|
||||
.empty-state,
|
||||
.connector-row,
|
||||
.workflow-rule,
|
||||
.workflow-step {
|
||||
@apply rounded-[1rem] border px-4 py-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.invite-row div,
|
||||
.connector-copy,
|
||||
.workflow-rule,
|
||||
.workflow-step-copy {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.connector-row {
|
||||
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
|
||||
}
|
||||
|
||||
.connector-main,
|
||||
.workflow-step {
|
||||
@apply flex items-start gap-4;
|
||||
}
|
||||
|
||||
.connector-icon,
|
||||
.workflow-step-icon {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.connector-status {
|
||||
@apply inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.connector-link {
|
||||
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.connector-link:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user