feat: pivot to social media workflow app
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
VITE_API_URL=https://localhost:5001/
|
||||
VITE_APP_BASE_URL=http://localhost:5173
|
||||
VITE_APP_API_URL=http://localhost:5173/api
|
||||
VITE_API_URL=http://192.168.1.2:5000
|
||||
VITE_STRIPE_API_KEY=pk_test_51OoveVDrRyqXtNdB2st1NgA8WQA9rhgGaf3q7bCpAOoQyyRS30HMCzGeHba7meVGCSPfb1BVWmOTmFOcr9MkKf5H00bLu5MqsS
|
||||
VITE_GOOGLE_CLIENT_ID=213344094492-9dbaet2gaschju3hj1sgv1umk0qpd833.apps.googleusercontent.com
|
||||
VITE_FACEBOOK_APP_ID
|
||||
VITE_FACEBOOK_APP_ID=1076433907621883
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
VITE_API_URL=https://hutopy-backend-api.azurewebsites.net
|
||||
VITE_APP_BASE_URL=https://hutopy.ca
|
||||
VITE_APP_API_URL=https://hutopy.ca/api
|
||||
VITE_STRIPE_API_KEY=51OoveVDrRyqXtNdBAxIo183PujtqFyU0xUMK9YNtIijcHeDlcLN6pqkZWHbgaBA0FHrwLMSoy3yVLN33NX8ExOxL00MSZwgJN7
|
||||
VITE_GOOGLE_CLIENT_ID=213344094492-7c83lqoh7mnjgadpeqo2lcs1krhbsnnd.apps.googleusercontent.com
|
||||
VITE_FACEBOOK_APP_ID=1076433907621883
|
||||
AZURE_SUBSCRIPTION_ID=46feb20f-3ae1-495a-830b-a31f7b76483d
|
||||
AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f
|
||||
AZURE_TENANT_ID=2f389c0d-131d-4de4-a7ac-03bab7e7a04f
|
||||
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -1,7 +1,3 @@
|
||||
# Ignore cert localhost
|
||||
localhost-key.pem
|
||||
localhost.pem
|
||||
|
||||
# Environment files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -7,13 +7,9 @@ Hutopia frontEnd. Using vue3 and vuetify3.
|
||||
|
||||
## System Setup
|
||||
|
||||
Setup SSL certificates for localhost on your machine. Use the following commands to generate and store the certificates.
|
||||
Local frontend runtime configuration lives in `.env.development` for development and `.env.production` for production. Update those files when changing API endpoints or OAuth client ids.
|
||||
|
||||
```sh
|
||||
openssl genrsa -out localhost-key.pem
|
||||
openssl req -new -key localhost-key.pem -out csr.pem
|
||||
openssl x509 -req -days 365 -in csr.pem -signkey localhost-key.pem -out localhost.pem
|
||||
```
|
||||
The dev server runs over HTTP on `http://localhost:5173`.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
|
||||
# Setting Up SSL for Local Development
|
||||
|
||||
## Installing Chocolatey (Windows Only)
|
||||
|
||||
### What is Chocolatey?
|
||||
Chocolatey is a package manager for Windows, making it easy to install and manage software packages via the command line.
|
||||
|
||||
### Steps to Install Chocolatey:
|
||||
|
||||
1. **Open PowerShell as Administrator**:
|
||||
- Press `Windows + X` and select **Windows PowerShell (Admin)** from the menu.
|
||||
|
||||
2. **Run the Following Command to Install Chocolatey**:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
```
|
||||
|
||||
3. **Verify the Installation**:
|
||||
Once the installation is complete, you can verify Chocolatey is installed by running the following command:
|
||||
|
||||
```powershell
|
||||
choco --version
|
||||
```
|
||||
|
||||
If Chocolatey is installed correctly, it will output the current version number.
|
||||
|
||||
### Common Chocolatey Commands
|
||||
|
||||
- **Install a package**:
|
||||
|
||||
```powershell
|
||||
choco install <package_name>
|
||||
```
|
||||
|
||||
- **Uninstall a package**:
|
||||
|
||||
```powershell
|
||||
choco uninstall <package_name>
|
||||
```
|
||||
|
||||
- **Upgrade all installed packages**:
|
||||
|
||||
```powershell
|
||||
choco upgrade all
|
||||
```
|
||||
|
||||
### Additional Notes
|
||||
|
||||
- Chocolatey is required for installing `mkcert` and other software dependencies.
|
||||
- Make sure to always run PowerShell as Administrator when using Chocolatey to install or manage packages.
|
||||
|
||||
---
|
||||
|
||||
## Setting Up SSL for Local Development
|
||||
|
||||
To ensure that your local development environment runs with SSL, follow these steps to generate a locally trusted SSL certificate using `mkcert`:
|
||||
|
||||
### Requirements
|
||||
|
||||
- Install [Node.js](https://nodejs.org) (which includes npm)
|
||||
- Install [mkcert](https://github.com/FiloSottile/mkcert)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Install `mkcert`**:
|
||||
- Install `mkcert` using Chocolatey on Windows (or use an appropriate package manager for other OS).
|
||||
- For Windows users, open PowerShell as Administrator and run:
|
||||
|
||||
```powershell
|
||||
choco install mkcert
|
||||
```
|
||||
|
||||
- For macOS users, run:
|
||||
|
||||
```bash
|
||||
brew install mkcert
|
||||
```
|
||||
|
||||
2. **Install the Local Certificate Authority (CA)**:
|
||||
After installing `mkcert`, run the following command to install the local CA:
|
||||
|
||||
```bash
|
||||
mkcert -install
|
||||
```
|
||||
|
||||
This will set up a trusted local CA to issue certificates.
|
||||
|
||||
3. **Generate SSL Certificates**:
|
||||
In your project root (or a preferred directory), generate the SSL certificate and key for `localhost`:
|
||||
|
||||
```bash
|
||||
mkcert localhost
|
||||
```
|
||||
|
||||
This will generate two files:
|
||||
- `localhost.pem` (the certificate)
|
||||
- `localhost-key.pem` (the private key)
|
||||
|
||||
4. **Update the `vite.config.js`**:
|
||||
Ensure your project is set up to use the generated certificate. Your `vite.config.js` should contain the following lines to enable SSL for the development server:
|
||||
|
||||
```javascript
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
https: {
|
||||
key: fs.readFileSync(path.resolve(__dirname, 'path_to_your_key/localhost-key.pem')),
|
||||
cert: fs.readFileSync(path.resolve(__dirname, 'path_to_your_cert/localhost.pem')),
|
||||
},
|
||||
host: 'localhost',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Replace `path_to_your_key` and `path_to_your_cert` with the location of your generated certificate and key files.
|
||||
|
||||
5. **Start the Development Server**:
|
||||
Run the development server with SSL by using:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. **Access the Application**:
|
||||
Open your browser and navigate to `https://localhost:3000` (or the port your Vite server is running on).
|
||||
|
||||
### Additional Notes
|
||||
|
||||
- If you encounter any issues related to SSL, ensure that the local CA is properly installed by running `mkcert -install` again.
|
||||
- Each developer should follow these steps to generate their own trusted SSL certificate. Avoid committing the generated `.pem` and `.key` files to version control for security reasons.
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
|
||||
<title>Hutopy</title>
|
||||
<title>Socialize</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -13,4 +13,4 @@
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC2322I83McDEox
|
||||
Pfpq3bNTaza/DtOEa80QuSnhjK0yk+qUJKYJByfPWQz6GhPmI9YpIRKlZtB5aPs8
|
||||
4qTBgbY/w6DxX5tRXaCwlVe6DN/8aKBS4vcAEryfIqPdAUMqog9sp74AGDGEAzKn
|
||||
28FduqX9FQ9TC+id5GY9NeiwTx2g1wM6Id5PycBZgCSxZcPEg5229doeg7LsmEQj
|
||||
837ZwJ6Aavxnyn5bCyZh4wUmxb8lAyFZokd3szY+NLhw3OnKJZ0mYVxHvrRxCzlG
|
||||
gaobDFqV3UWTq+a42S6WvS4JKtZq+LHAAy1kZpSY0tfFdUwTuT/lxevNy7VLFVup
|
||||
gpWGcofpAgMBAAECggEAU5DuAPMe2uZS0QW9dTAyTiBkOBKSXaTVZJr4pHUggEhP
|
||||
nbrRlLaMXpgW8gMQrM4bg1f1qVe+VHzAsiXvm+2mVqUS2roRw7DBSXA1UnOntzQo
|
||||
bzgAHyxwvVebAdcd1lGQMtrEXE6x8d10PHiTeD1etLP2+MAsYFqKzdXgqxC8PU69
|
||||
I3ee/O6noGbpzddm5je93qoqncfIO6zd1PqYYkr93+9yffn+dbeOxWOnPHkZNiRB
|
||||
tYd4D5Cfin2NL+8pQ5BBPdt8xktOFKEhFTOgazV9qwU6ZJAg+UbwUKP1mogpNMhQ
|
||||
4Ci8T+RQmUfBIq07a1h6ksCTbtS4ByXs8HEA95+FwQKBgQDEeBQJkteDxthZFDMt
|
||||
vO3TaHoFptKIYgJ6zxJR4ngo2UOc/cPM876qLNvw2p/6E2hnpGMYAZb6o9ipAQQR
|
||||
WZm52F29X/rtmU2E/03QPE/fCdj3KfZUxtA+xbJ0ecqKgv99Hi0mkJrIUEWgO5n3
|
||||
0CG87J1dR1HUPqJjbDYkpmz9PQKBgQDuSLKvzipOxXpnJlV0x9PZXWzx+elCT1Hm
|
||||
uK8j5bagb+MjQdh+u4g3Kgf2g73pl8w80kQp9vExJcmmfLNTcPpbeUL1BXF40NdE
|
||||
AP/DChN+ynZCFpmE3zIOjy9B7txq+qnu10yrxF6vaUVbjoVZ3d6mZ0QQJ45exqUy
|
||||
p4S1u864HQKBgHarVOcHc/dbhtgfVF5fDIOySmnZfrb0BC1rn9Qn54481RMhUEAe
|
||||
Rd8CI4MSeqiRSnG3oEcixq/zgW1reKqGJU1UvCIjtCwJegJINxb9Jv1ANHXuOaSx
|
||||
RZ10yjqCSe1p/Kn1LS5rD6LIoZWMCo7df1Ne1BpAdtOtVWaaOQXgJFq9AoGAJx5K
|
||||
L3ByI6Jp2NtDNjvD/LBIvWTgtWEeOflhz0vb8nTL3jLmHtAcqam9yuuP1vRztBx0
|
||||
0krXB9GDTFC2g+FNSI0cv+rX2RS38lMTqepSjwMf7POW2mhl6Fv7TyCukOV71lkE
|
||||
HkLLpJJsr34zSDCTZ9AWLWzBA7Aq2KkFsWwWoMUCgYBFYcc3qhMP08fsjSO0Tlu6
|
||||
Bqmxm1t/+1bGGOPtZOQNcKQRLJfa/c7Cp2F6vYK1yXeautJvoZxrbVptS39yGgMd
|
||||
I90MkxH5XpFpJYd3tX9QP021AdmXRtk0KBIQobs/y2bnxiT+kcELEq17g1V7MAft
|
||||
WI16saz8M5wPtM62SxKjxQ==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,25 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIETDCCArSgAwIBAgIQBSpXJsGF49z8CNN9ViHXjTANBgkqhkiG9w0BAQsFADCB
|
||||
iTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS8wLQYDVQQLDCZKTy1E
|
||||
RVZcbG93cmFAam8tZGV2IChKb25hdGhhbiBCb3VyZG9uKTE2MDQGA1UEAwwtbWtj
|
||||
ZXJ0IEpPLURFVlxsb3dyYUBqby1kZXYgKEpvbmF0aGFuIEJvdXJkb24pMB4XDTI0
|
||||
MTAwODAzNDIxNFoXDTI3MDEwODA0NDIxNFowWjEnMCUGA1UEChMebWtjZXJ0IGRl
|
||||
dmVsb3BtZW50IGNlcnRpZmljYXRlMS8wLQYDVQQLDCZKTy1ERVZcbG93cmFAam8t
|
||||
ZGV2IChKb25hdGhhbiBCb3VyZG9uKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
|
||||
AQoCggEBALbfbYjzcxwMSjE9+mrds1NrNr8O04RrzRC5KeGMrTKT6pQkpgkHJ89Z
|
||||
DPoaE+Yj1ikhEqVm0Hlo+zzipMGBtj/DoPFfm1FdoLCVV7oM3/xooFLi9wASvJ8i
|
||||
o90BQyqiD2ynvgAYMYQDMqfbwV26pf0VD1ML6J3kZj016LBPHaDXAzoh3k/JwFmA
|
||||
JLFlw8SDnbb12h6DsuyYRCPzftnAnoBq/GfKflsLJmHjBSbFvyUDIVmiR3ezNj40
|
||||
uHDc6colnSZhXEe+tHELOUaBqhsMWpXdRZOr5rjZLpa9Lgkq1mr4scADLWRmlJjS
|
||||
18V1TBO5P+XF683LtUsVW6mClYZyh+kCAwEAAaNeMFwwDgYDVR0PAQH/BAQDAgWg
|
||||
MBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFMJlZrGHE5r1pdB1VMFr
|
||||
fa/gSQhoMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEA
|
||||
u+p2G45y2ReLLgC1oIDc3j/Gl+mXQ9AMgj/aIrRE/iZy6wedd2/2AEKpK9e9Dp/z
|
||||
ckXZoB9D1gcfigQV+xwjAGO939eteDJiYlksB99ujKP5lP6wxJV2kWuQ5THB9b0x
|
||||
P/td5U2/Va0wElRwg2q7k8+IYRye9A0dK+3ofiFQ3zThgmeq0tBC0DkhFqVVbdO1
|
||||
1uhgp2SyF7iHB6pIELBlWAXo50wFx+smshFUxX1FT7Y1SbvknZvqFWyyQvD4lymG
|
||||
EOInURWL4MaMM+JuIbgOabVawaG6sBXhjHNsoYtm6ttbpaMNRpprJ/skY816FtMY
|
||||
FLq5CkJep9qyNy0Y6N02pb6LPRgoSv9dgx3uY9iD4mgf2vUx9vZBvLvAzF1nQ+PE
|
||||
WJMlcYry0jE0xy37jNLpUqYALf3gYVwYwnxqe0ytKwHrDXcdkDXROnMJNYsKjZbc
|
||||
MO4yatpPclBgod6yF4RLc5B08f+jOWzqsHdUSUPITeVZSFaE5UjColhT3l9JxiCB
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,53 +1,100 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<div class="shell-container">
|
||||
<div class="shell-side">
|
||||
<site-bar></site-bar>
|
||||
</div>
|
||||
<app-bar
|
||||
:show-brand="true"
|
||||
:collapse-brand="showsAppSidebar && !isSidebarExpanded"
|
||||
/>
|
||||
|
||||
<div class="shell-view">
|
||||
<router-view></router-view>
|
||||
<div
|
||||
class="shell-main"
|
||||
:class="{ 'shell-main-app': showsAppSidebar }"
|
||||
>
|
||||
<template v-if="showsAppSidebar">
|
||||
<div class="shell-sidebar-wrap">
|
||||
<app-sidebar :is-expanded="isSidebarExpanded" />
|
||||
|
||||
<button
|
||||
class="sidebar-boundary-toggle"
|
||||
type="button"
|
||||
@click="isSidebarExpanded = !isSidebarExpanded"
|
||||
>
|
||||
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="shell-view">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script async setup>
|
||||
import SiteBar from '@/views/main/SiteBar.vue';
|
||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
||||
import { watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import AppBar from '@/views/main/AppBar.vue';
|
||||
import AppSidebar from '@/views/main/AppSidebar.vue';
|
||||
|
||||
// Watch for language changes and update i18n locale
|
||||
const languageStore = useLanguageStore();
|
||||
const { locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const isSidebarExpanded = ref(true);
|
||||
|
||||
// Watch for changes to the language store
|
||||
watch(
|
||||
() => languageStore.locale,
|
||||
newLocale => {
|
||||
if (newLocale) {
|
||||
locale.value = newLocale;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
const showsAppSidebar = computed(() =>
|
||||
authStore.isAuthenticated && route.path.startsWith('/app')
|
||||
);
|
||||
|
||||
watch(showsAppSidebar, value => {
|
||||
if (!value) {
|
||||
isSidebarExpanded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSidebarExpanded.value = true;
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shell-container {
|
||||
@apply flex flex-col;
|
||||
@apply w-full;
|
||||
@apply font-sans;
|
||||
@apply bg-hBackground text-hOnBackground;
|
||||
@apply min-h-screen flex flex-col;
|
||||
@apply w-full font-sans;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.shell-side {
|
||||
@apply flex-shrink-0;
|
||||
.shell-main {
|
||||
@apply relative flex flex-1 flex-col;
|
||||
}
|
||||
|
||||
.shell-main-app {
|
||||
@apply md:flex-row md:items-start;
|
||||
}
|
||||
|
||||
.shell-sidebar-wrap {
|
||||
@apply relative flex-shrink-0;
|
||||
}
|
||||
|
||||
.sidebar-boundary-toggle {
|
||||
@apply absolute left-full top-8 z-10 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
|
||||
background: rgba(255, 250, 242, 0.98);
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
color: #44516a;
|
||||
box-shadow: 0 12px 28px rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-boundary-toggle:hover {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.shell-view {
|
||||
@apply flex-grow;
|
||||
@apply flex justify-center items-center;
|
||||
@apply flex min-w-0 flex-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Branding Colors */
|
||||
--hutopy-primary: #6B0065;
|
||||
--hutopy-secondary: #A30E79;
|
||||
|
||||
/* UI COLORS */
|
||||
--h-background: #1c181c;
|
||||
--h-on-background: #e2e5e9;
|
||||
--h-surface: #252225;
|
||||
--h-on-surface: #e2e5e9;
|
||||
--h-primary: #242b2b;
|
||||
--h-on-primary: #e2e5e9;
|
||||
--h-secondary: #e7e5ea;
|
||||
--h-on-secondary: #000000;
|
||||
--h-tertiary: #466568;
|
||||
--h-on-tertiary: #bdb6b6;
|
||||
--socialize-primary: #172033;
|
||||
--socialize-accent: #ff8a3d;
|
||||
--socialize-highlight: #2fa58d;
|
||||
--h-background: #fffaf2;
|
||||
--h-on-background: #172033;
|
||||
--h-surface: #ffffff;
|
||||
--h-on-surface: #172033;
|
||||
--h-primary: #172033;
|
||||
--h-on-primary: #fffaf2;
|
||||
--h-secondary: #fff3e2;
|
||||
--h-on-secondary: #172033;
|
||||
--h-tertiary: #d9f6ee;
|
||||
--h-on-tertiary: #0f766e;
|
||||
--h-error: #bc2f2f;
|
||||
--h-on-error: #ffffff;
|
||||
}
|
||||
|
||||
79
frontend/src/components/AppAvatar.vue
Normal file
79
frontend/src/components/AppAvatar.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
const initials = computed(() => {
|
||||
const basis = props.name?.trim() || props.email?.trim() || '?';
|
||||
const parts = basis.split(/[\s@._-]+/).filter(Boolean);
|
||||
|
||||
if (!parts.length) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`.toUpperCase();
|
||||
});
|
||||
|
||||
const classes = computed(() => ({
|
||||
avatar: true,
|
||||
'avatar-sm': props.size === 'sm',
|
||||
'avatar-md': props.size === 'md',
|
||||
'avatar-lg': props.size === 'lg',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:alt="name || email || 'Avatar'"
|
||||
/>
|
||||
<span v-else>{{ initials }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
@apply h-full w-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
@apply h-9 w-9 text-xs;
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
@apply h-11 w-11 text-sm;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
@apply h-14 w-14 text-base;
|
||||
}
|
||||
</style>
|
||||
365
frontend/src/components/ImageCropperDialog.vue
Normal file
365
frontend/src/components/ImageCropperDialog.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { Cropper } from 'vue-advanced-cropper';
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Edit image',
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: 'Save image',
|
||||
},
|
||||
aspectRatio: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
uploadLabel: {
|
||||
type: String,
|
||||
default: 'Choose image',
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sourceLabel: {
|
||||
type: String,
|
||||
default: 'Image URL',
|
||||
},
|
||||
loadLabel: {
|
||||
type: String,
|
||||
default: 'Load URL',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const cropper = ref(null);
|
||||
const imageUrl = ref(null);
|
||||
const remoteUrl = ref('');
|
||||
const error = ref(null);
|
||||
|
||||
const isReady = computed(() => Boolean(imageUrl.value));
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function revokeImageUrl() {
|
||||
if (imageUrl.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
revokeImageUrl();
|
||||
imageUrl.value = props.initialUrl || null;
|
||||
remoteUrl.value = props.initialUrl || '';
|
||||
error.value = null;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImage() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event) {
|
||||
const [file] = event.target.files ?? [];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
revokeImageUrl();
|
||||
imageUrl.value = URL.createObjectURL(file);
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
function loadImageFromUrl() {
|
||||
if (!remoteUrl.value) {
|
||||
error.value = 'Enter an image URL before loading it.';
|
||||
return;
|
||||
}
|
||||
|
||||
revokeImageUrl();
|
||||
imageUrl.value = remoteUrl.value;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
function zoom(factor) {
|
||||
cropper.value?.zoom(factor);
|
||||
}
|
||||
|
||||
function rotate(angle) {
|
||||
cropper.value?.rotate(angle);
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
const result = cropper.value?.getResult();
|
||||
const canvas = result?.canvas;
|
||||
|
||||
if (!canvas) {
|
||||
error.value = 'Select an image before saving.';
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 0.92));
|
||||
|
||||
if (!blob) {
|
||||
error.value = 'The edited image could not be prepared.';
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([blob], 'cropped-image.png', { type: 'image/png' });
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.92);
|
||||
await emit('save', { file, dataUrl, sourceUrl: remoteUrl.value || imageUrl.value });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
isOpen => {
|
||||
if (isOpen) {
|
||||
resetState();
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeImageUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="920"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="cropper-card">
|
||||
<div class="cropper-header">
|
||||
<div>
|
||||
<div class="cropper-eyebrow">Image editor</div>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="plain-button"
|
||||
:disabled="isSaving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cropper-actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="action-button"
|
||||
:disabled="isSaving"
|
||||
@click="chooseImage"
|
||||
>
|
||||
{{ uploadLabel }}
|
||||
</button>
|
||||
<div class="url-controls">
|
||||
<input
|
||||
v-model="remoteUrl"
|
||||
type="url"
|
||||
class="url-input"
|
||||
:placeholder="sourceLabel"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="isSaving"
|
||||
@click="loadImageFromUrl"
|
||||
>
|
||||
{{ loadLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="zoom(1.15)"
|
||||
>
|
||||
Zoom in
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="zoom(0.85)"
|
||||
>
|
||||
Zoom out
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="rotate(-90)"
|
||||
>
|
||||
Rotate left
|
||||
</button>
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="rotate(90)"
|
||||
>
|
||||
Rotate right
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="error-message"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="cropper-stage"
|
||||
>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="imageUrl"
|
||||
:stencil-props="{ aspectRatio }"
|
||||
image-restriction="stencil"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
Choose an image to crop and upload.
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button
|
||||
class="action-button secondary"
|
||||
:disabled="isSaving"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
:disabled="!isReady || isSaving"
|
||||
@click="saveCrop"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="isSaving"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cropper-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.cropper-header {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.cropper-eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.cropper-header h2 {
|
||||
@apply mt-2 text-2xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.cropper-actions,
|
||||
.footer-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.url-controls {
|
||||
@apply flex min-w-full flex-wrap gap-3 md:min-w-0 md:flex-1;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply justify-end;
|
||||
}
|
||||
|
||||
.action-button,
|
||||
.plain-button {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.action-button.secondary,
|
||||
.plain-button {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
color: #172033;
|
||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.cropper-stage {
|
||||
@apply overflow-hidden rounded-[1.5rem] border;
|
||||
height: 28rem;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.error-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
background: rgba(255, 250, 242, 0.9);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
border-color: rgba(185, 28, 28, 0.12);
|
||||
color: #b91c1c;
|
||||
background: rgba(254, 226, 226, 0.75);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,11 @@
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useHead} from "@vueuse/head";
|
||||
import {useAuthStore} from "@/stores/authStore.js";
|
||||
import config from "@/config.js";
|
||||
|
||||
export function useFacebookLogin() {
|
||||
const isSdkLoaded = ref(false);
|
||||
|
||||
/* TODO: FIND THE ACTUAL HUTOPY'S APP_ID */
|
||||
const FACEBOOK_APP_ID = "1076433907621883";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
@@ -33,7 +31,7 @@ export function useFacebookLogin() {
|
||||
const initializeFacebookSDK = () => {
|
||||
window.fbAsyncInit = function () {
|
||||
FB.init({
|
||||
appId: FACEBOOK_APP_ID,
|
||||
appId: config.facebookAppId,
|
||||
xfbml: true,
|
||||
version: 'v22.0'
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// Environment-specific configuration
|
||||
const config = {
|
||||
baseUrl: import.meta.env.VITE_APP_BASE_URL || 'https://hutopy.ca',
|
||||
apiUrl: import.meta.env.VITE_APP_API_URL || 'https://hutopy.ca/api',
|
||||
};
|
||||
function getRequiredEnv(name) {
|
||||
const value = import.meta.env[name];
|
||||
|
||||
export default config;
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not provided`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const config = Object.freeze({
|
||||
apiUrl: getRequiredEnv('VITE_API_URL'),
|
||||
googleClientId: getRequiredEnv('VITE_GOOGLE_CLIENT_ID'),
|
||||
facebookAppId: getRequiredEnv('VITE_FACEBOOK_APP_ID'),
|
||||
stripeApiKey: getRequiredEnv('VITE_STRIPE_API_KEY'),
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -33,9 +33,437 @@
|
||||
"x": "X (Twitter)",
|
||||
"youtube": "YouTube",
|
||||
"website": "Website",
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"creating": "Creating..."
|
||||
},
|
||||
"workspaceSelector": {
|
||||
"createAction": "Add workspace"
|
||||
},
|
||||
"workspaceCreate": {
|
||||
"eyebrow": "Workspace",
|
||||
"title": "Create a new workspace",
|
||||
"description": "Set up a new workspace with its own slug, timezone, members, workflow, and connectors.",
|
||||
"previewTitle": "Workspace URL",
|
||||
"previewDescription": "The slug becomes the stable identifier used for the workspace.",
|
||||
"formTitle": "Workspace details",
|
||||
"formDescription": "Start with the core fields now. Members, workflow, and connectors can be configured right after creation.",
|
||||
"createAction": "Create workspace",
|
||||
"slugHint": "Workspace slug preview: {slug}",
|
||||
"fields": {
|
||||
"name": "Workspace name",
|
||||
"namePlaceholder": "Northwind Studio",
|
||||
"slug": "Workspace slug",
|
||||
"slugPlaceholder": "northwind-studio",
|
||||
"timeZone": "Time zone"
|
||||
},
|
||||
"errors": {
|
||||
"required": "All workspace fields are required.",
|
||||
"createFailed": "The workspace could not be created."
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"brandCaption": "Approval workflow",
|
||||
"workspace": "Workspace",
|
||||
"notifications": "Notifications",
|
||||
"dashboard": "Dashboard",
|
||||
"overview": "Overview",
|
||||
"workspacePlan": "Content",
|
||||
"mediaLibrary": "Media Library",
|
||||
"channels": "Channels",
|
||||
"projects": "Campaigns",
|
||||
"reviewQueue": "Review Queue",
|
||||
"content": "Content",
|
||||
"profile": "Profile",
|
||||
"signIn": "Sign in",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"signOut": "Sign out",
|
||||
"noWorkspace": "No workspace"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"unread": "unread",
|
||||
"loading": "Loading notifications...",
|
||||
"empty": "No workflow notifications yet.",
|
||||
"events": {
|
||||
"approvalRequested": "Approval requested",
|
||||
"approvalDecisionRecorded": "Approval decision recorded",
|
||||
"commentCreated": "Comment added",
|
||||
"commentResolved": "Comment resolved",
|
||||
"contentCreated": "Content item created",
|
||||
"revisionCreated": "Revision created",
|
||||
"statusUpdated": "Status updated",
|
||||
"assetLinked": "Asset linked",
|
||||
"assetRevisionCreated": "Asset revision created"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"allClients": "All clients",
|
||||
"allChannels": "All channels",
|
||||
"allProjects": "All campaigns",
|
||||
"allReviewItems": "Full review queue",
|
||||
"noClients": "No clients yet.",
|
||||
"noChannels": "No channels yet.",
|
||||
"noProjects": "No campaigns yet.",
|
||||
"noReviewItems": "No review items right now."
|
||||
},
|
||||
"settings": {
|
||||
"eyebrow": "Settings",
|
||||
"title": "Account settings",
|
||||
"userInformation": "User information",
|
||||
"workspaces": "Workspaces",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"dashboard": {
|
||||
"eyebrow": "Workspace schedule",
|
||||
"title": "Schedule",
|
||||
"description": "See what is scheduled for a given day and review the posting agenda in order.",
|
||||
"workspaceLabel": "Active workspace",
|
||||
"loading": "Loading workspace data...",
|
||||
"calendarKicker": "Daily agenda",
|
||||
"executionKicker": "Next up",
|
||||
"riskKicker": "Delivery risk",
|
||||
"reviewKicker": "Review pulse",
|
||||
"upcomingContent": "Upcoming content",
|
||||
"deliveryRisks": "What can slip",
|
||||
"overdueItems": "Overdue items",
|
||||
"approvalBlockers": "Awaiting approval or revisions",
|
||||
"unscheduledProjects": "Campaigns without scheduled content",
|
||||
"reviewQueueSnapshot": "Review queue snapshot",
|
||||
"emptyUpcoming": "No upcoming scheduled content.",
|
||||
"emptyOverdue": "Nothing overdue right now.",
|
||||
"emptyApproval": "No approval blockers at the moment.",
|
||||
"emptyProjects": "Every campaign has at least one scheduled content item.",
|
||||
"emptyReviewQueue": "No active review queue items.",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"today": "Today",
|
||||
"month": "Month",
|
||||
"week": "Week",
|
||||
"campaignDeadline": "Campaign deadline",
|
||||
"emptyPeriod": "No scheduled items.",
|
||||
"daySummary": "{content} content items · {projects} campaign deadlines",
|
||||
"moreItems": "+{count} more",
|
||||
"emptyDayAgenda": "No content is scheduled for this day.",
|
||||
"projectProgress": "{scheduled} scheduled · {approved} approved",
|
||||
"missingSchedule": "Needs content scheduled",
|
||||
"noDueDate": "No due date",
|
||||
"labels": {
|
||||
"unassignedProject": "Unassigned campaign"
|
||||
},
|
||||
"readiness": {
|
||||
"building": "In production",
|
||||
"approval": "Awaiting approval",
|
||||
"rework": "Needs revision",
|
||||
"ready": "Ready to publish",
|
||||
"published": "Published",
|
||||
"blocked": "Blocked",
|
||||
"archived": "Archived",
|
||||
"scheduled": "Scheduled",
|
||||
"missing": "No content scheduled"
|
||||
},
|
||||
"stats": {
|
||||
"scheduledThisDay": "Scheduled this day",
|
||||
"overdue": "Overdue",
|
||||
"awaitingApproval": "Awaiting approval",
|
||||
"readyToShip": "Ready to ship"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "Portfolio overview",
|
||||
"title": "Cross-workspace timeline",
|
||||
"description": "See upcoming deliveries, risks, and activity across every workspace you can access.",
|
||||
"loading": "Loading overview data...",
|
||||
"workspacesKicker": "Access scope",
|
||||
"workspaceRollup": "Workspace rollup",
|
||||
"timelineKicker": "Upcoming",
|
||||
"upcomingTitle": "Scheduled across workspaces",
|
||||
"riskKicker": "Watch list",
|
||||
"risksTitle": "Items already at risk",
|
||||
"activityKicker": "Recent activity",
|
||||
"activityTitle": "Latest workflow events",
|
||||
"emptyUpcoming": "No upcoming scheduled items across your workspaces.",
|
||||
"emptyRisks": "No cross-workspace delivery risks right now.",
|
||||
"emptyActivity": "No recent workflow activity yet.",
|
||||
"labels": {
|
||||
"projects": "campaigns",
|
||||
"upcoming": "upcoming",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"stats": {
|
||||
"workspaces": "Workspaces",
|
||||
"projects": "Campaigns",
|
||||
"upcoming": "Upcoming items",
|
||||
"blockers": "At-risk items"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"eyebrow": "Client management",
|
||||
"title": "Clients",
|
||||
"description": "Client accounts, brand identity, and primary approval contacts.",
|
||||
"newClient": "New client",
|
||||
"createTitle": "Create client",
|
||||
"loading": "Loading clients...",
|
||||
"empty": "No clients are available for the active workspace.",
|
||||
"noPrimaryContact": "No primary contact set",
|
||||
"noPrimaryContactEmail": "No primary contact email set",
|
||||
"errors": {
|
||||
"nameRequired": "Client name is required.",
|
||||
"createFailed": "The client could not be created."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Client name",
|
||||
"portraitUrl": "Client logo or portrait URL",
|
||||
"primaryContactName": "Primary contact name",
|
||||
"primaryContactEmail": "Primary contact email",
|
||||
"primaryContactPortraitUrl": "Primary contact portrait URL"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"eyebrow": "Campaign planning",
|
||||
"title": "Campaigns",
|
||||
"description": "Campaigns grouped inside the active workspace by status, date range, and planning notes.",
|
||||
"newProject": "New campaign",
|
||||
"createTitle": "Create campaign",
|
||||
"loading": "Loading campaigns...",
|
||||
"empty": "No campaigns are available for the active workspace.",
|
||||
"unknownClient": "Unknown client",
|
||||
"noDateRange": "No date range",
|
||||
"errors": {
|
||||
"required": "Campaign name and date range are required.",
|
||||
"invalidDateRange": "The end date must be on or after the start date.",
|
||||
"workspaceAccountRequired": "This workspace needs an operational account before campaigns can be created.",
|
||||
"createFailed": "The campaign could not be created."
|
||||
},
|
||||
"fields": {
|
||||
"client": "Client",
|
||||
"selectClient": "Select a client",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"name": "Campaign name",
|
||||
"description": "Description",
|
||||
"notes": "Notes"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"description": "Add channels to the workspace.",
|
||||
"createTitle": "Create channel",
|
||||
"empty": "No channels are available for the active workspace yet.",
|
||||
"emptyAction": "Add a channel for {network}",
|
||||
"nextDue": "Next due",
|
||||
"noScheduled": "Nothing scheduled",
|
||||
"fields": {
|
||||
"name": "Channel name",
|
||||
"network": "Network"
|
||||
},
|
||||
"metrics": {
|
||||
"scheduled": "Scheduled",
|
||||
"ready": "Ready",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "The channel could not be created."
|
||||
}
|
||||
},
|
||||
"reviewQueue": {
|
||||
"eyebrow": "Review workflow",
|
||||
"title": "Review queue",
|
||||
"description": "Pending approvals, revisions, and change requests for the active workspace.",
|
||||
"empty": "No review items are available for the active workspace."
|
||||
},
|
||||
"contentItems": {
|
||||
"eyebrow": "Content workflow",
|
||||
"title": "Content items",
|
||||
"description": "Reviewable units with assets, copy, and approval status inside the active workspace.",
|
||||
"newItem": "New content item",
|
||||
"createTitle": "Create content item",
|
||||
"loading": "Loading content items...",
|
||||
"empty": "No content items are available for the active workspace.",
|
||||
"noDueDate": "No due date",
|
||||
"assetsHelper": "Google Drive assets are now linked from the content item detail page after creation.",
|
||||
"errors": {
|
||||
"required": "Title, campaign, message, and targets are required.",
|
||||
"workspaceAccountRequired": "This workspace needs an operational account before content can be created.",
|
||||
"createFailed": "The content item could not be created."
|
||||
},
|
||||
"fields": {
|
||||
"title": "Title",
|
||||
"client": "Client",
|
||||
"selectClient": "Select a client",
|
||||
"project": "Campaign",
|
||||
"selectProject": "Select a campaign",
|
||||
"dueDate": "Due date",
|
||||
"publicationTargets": "Publication targets",
|
||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
||||
"publicationMessage": "Publication message",
|
||||
"hashtags": "Hashtags",
|
||||
"hashtagsPlaceholder": "#launch #brand #campaign",
|
||||
"assets": "Assets"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
"eyebrow": "User information",
|
||||
"title": "Profile and identity",
|
||||
"description": "Manage the portrait and account details shown inside the workspace.",
|
||||
"updatePortrait": "Update portrait",
|
||||
"accountDetails": "Account details",
|
||||
"accountDetailsDescription": "Additional account editing fields can be added here next.",
|
||||
"alias": "Alias",
|
||||
"fullName": "Full name",
|
||||
"email": "Email",
|
||||
"noEmail": "No email set",
|
||||
"cropperTitle": "Update user portrait",
|
||||
"savePortrait": "Save portrait",
|
||||
"choosePortrait": "Choose portrait"
|
||||
},
|
||||
"workspaceSettings": {
|
||||
"eyebrow": "Settings",
|
||||
"title": "Workspace settings",
|
||||
"description": "Configure the current workspace across general details, members, workflow, and connectors.",
|
||||
"currentWorkspace": "Current workspace",
|
||||
"noWorkspaceSelected": "No workspace selected",
|
||||
"activeWorkspace": "Active workspace",
|
||||
"contextNote": "These settings apply to the current workspace only.",
|
||||
"inviteTitle": "Invite workspace members",
|
||||
"inviteDescription": "Invite clients, subcontractors, or teammates into the active workspace.",
|
||||
"inviteEmpty": "No pending invites for this workspace yet.",
|
||||
"sendInvite": "Send invite",
|
||||
"reset": "Reset",
|
||||
"errors": {
|
||||
"required": "All workspace fields are required.",
|
||||
"createFailed": "The workspace could not be created.",
|
||||
"inviteRequired": "Email and role are required to invite a member.",
|
||||
"inviteFailed": "The workspace invite could not be created."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Workspace name",
|
||||
"slug": "Workspace slug",
|
||||
"timeZone": "Time zone",
|
||||
"memberEmail": "Member email",
|
||||
"memberRole": "Role"
|
||||
},
|
||||
"roles": {
|
||||
"administrator": "Administrator",
|
||||
"manager": "Manager",
|
||||
"client": "Client reviewer",
|
||||
"provider": "Subcontractor",
|
||||
"workspaceMember": "Workspace member"
|
||||
},
|
||||
"summary": {
|
||||
"name": "Name",
|
||||
"slug": "Slug",
|
||||
"timeZone": "Time zone",
|
||||
"created": "Created"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"members": "Members",
|
||||
"workflow": "Workflow",
|
||||
"connectors": "Connectors"
|
||||
},
|
||||
"members": {
|
||||
"inviteTitle": "Invite",
|
||||
"activeTitle": "Members",
|
||||
"activeDescription": "See everyone who currently belongs to the active workspace.",
|
||||
"activeEmpty": "No members found for this workspace.",
|
||||
"pendingTitle": "Pending invitations",
|
||||
"pendingDescription": "Track who has been invited into the active workspace."
|
||||
},
|
||||
"connectors": {
|
||||
"title": "Connectors",
|
||||
"description": "Manage workspace-level connectors that feed operational features like the media library.",
|
||||
"openMediaLibrary": "Open media library",
|
||||
"googleDrive": {
|
||||
"title": "Google Drive",
|
||||
"description": "This connector should power the workspace media sync for images, videos, and other shared files.",
|
||||
"status": "Pending setup"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"summaryTitle": "Workspace summary",
|
||||
"summaryDescription": "Reference details for the workspace currently in context."
|
||||
},
|
||||
"approvals": {
|
||||
"flowTitle": "Approval flow",
|
||||
"flowDescription": "Personalize how content moves through internal review, client review, and publishing for this workspace.",
|
||||
"previewTitle": "Flow preview",
|
||||
"previewDescription": "This is the sequence the workspace will use based on the current configuration.",
|
||||
"saved": "Approval flow saved for this workspace in this browser.",
|
||||
"fields": {
|
||||
"requireInternalReview": "Require internal review",
|
||||
"internalApproversRequired": "Internal approvers required",
|
||||
"requireClientReview": "Require client review",
|
||||
"clientApproversRequired": "Client approvers required",
|
||||
"defaultReviewerRole": "Default reviewer role",
|
||||
"publishBehaviour": "After final approval"
|
||||
},
|
||||
"fieldHelp": {
|
||||
"requireInternalReview": "Content must be approved internally before client review can begin.",
|
||||
"requireClientReview": "Content must still pass through client approval before publication."
|
||||
},
|
||||
"publishBehaviour": {
|
||||
"manual": "Mark ready to publish",
|
||||
"auto": "Auto-advance to ready"
|
||||
},
|
||||
"steps": {
|
||||
"internal": "Internal review",
|
||||
"client": "Client review",
|
||||
"publish": "Publishing handoff"
|
||||
},
|
||||
"stepDetail": {
|
||||
"approverCount": "{count} approver(s) required",
|
||||
"autoPublish": "Content moves to ready automatically after the final approval.",
|
||||
"manualPublish": "Content stays in a manual ready-to-publish handoff after the final approval."
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"eyebrow": "Integrations",
|
||||
"title": "Google Drive and API keys",
|
||||
"description": "This is where workspace-level integrations and credential configuration should live.",
|
||||
"statusLabel": "Status",
|
||||
"pendingTitle": "Configuration UI pending",
|
||||
"googleDrive": {
|
||||
"title": "Google Drive",
|
||||
"description": "Configure the workspace connection used for asset linking and revision intake.",
|
||||
"nextStep": "Next step: add stored workspace integration settings and connect them to the asset-link flow."
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "API keys",
|
||||
"description": "Workspace-scoped secrets and external service credentials should be managed here.",
|
||||
"nextStep": "Next step: add secure backend persistence and masked key management."
|
||||
}
|
||||
},
|
||||
"mediaLibrary": {
|
||||
"eyebrow": "Media library",
|
||||
"title": "Workspace media",
|
||||
"description": "Manage the shared image and video library that should sync with Google Drive for this workspace.",
|
||||
"syncCard": {
|
||||
"title": "Google Drive sync",
|
||||
"description": "Use this area to connect the workspace drive, pull approved media in, and keep the library aligned with external folders."
|
||||
},
|
||||
"mediaTypesTitle": "Supported media",
|
||||
"mediaTypesDescription": "The library should become the single place to browse visual assets before they are linked into content work.",
|
||||
"mediaTypes": {
|
||||
"images": "Images, graphics, and brand visuals",
|
||||
"videos": "Videos, reels, and motion exports"
|
||||
},
|
||||
"workflowTitle": "Planned workflow",
|
||||
"workflowDescription": "This page is the intended home for the Google Drive sync flow we discussed.",
|
||||
"workflow": {
|
||||
"connectDrive": "Connect the workspace Google Drive source.",
|
||||
"syncAssets": "Sync image and video assets into the internal library.",
|
||||
"organizeLibrary": "Review, tag, and reuse media from one workspace-level place."
|
||||
},
|
||||
"statusLabel": "Status",
|
||||
"pendingTitle": "Management UI pending",
|
||||
"pendingDescription": "The navigation and page entry point are in place. Next step is wiring actual Drive sync, listing, filters, and asset actions."
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "An unexpected error occurred",
|
||||
"imageLoad": "Error loading image",
|
||||
"imageUpload": "Error uploading image"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,437 @@
|
||||
"x": "X (Twitter)",
|
||||
"youtube": "YouTube",
|
||||
"website": "Site web",
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"creating": "Création..."
|
||||
},
|
||||
"workspaceSelector": {
|
||||
"createAction": "Ajouter un espace"
|
||||
},
|
||||
"workspaceCreate": {
|
||||
"eyebrow": "Espace",
|
||||
"title": "Creer un nouvel espace",
|
||||
"description": "Configurez un nouvel espace avec son slug, son fuseau horaire, ses membres, son workflow et ses connecteurs.",
|
||||
"previewTitle": "URL de l'espace",
|
||||
"previewDescription": "Le slug devient l'identifiant stable utilise pour l'espace.",
|
||||
"formTitle": "Details de l'espace",
|
||||
"formDescription": "Commencez par les champs essentiels. Les membres, le workflow et les connecteurs peuvent etre configures juste apres la creation.",
|
||||
"createAction": "Creer l'espace",
|
||||
"slugHint": "Apercu du slug : {slug}",
|
||||
"fields": {
|
||||
"name": "Nom de l'espace",
|
||||
"namePlaceholder": "Northwind Studio",
|
||||
"slug": "Slug de l'espace",
|
||||
"slugPlaceholder": "northwind-studio",
|
||||
"timeZone": "Fuseau horaire"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Tous les champs de l'espace sont requis.",
|
||||
"createFailed": "L'espace n'a pas pu etre cree."
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"brandCaption": "Flux d'approbation",
|
||||
"workspace": "Espace de travail",
|
||||
"notifications": "Notifications",
|
||||
"dashboard": "Tableau de bord",
|
||||
"overview": "Vue globale",
|
||||
"workspacePlan": "Contenu",
|
||||
"mediaLibrary": "Bibliotheque media",
|
||||
"channels": "Canaux",
|
||||
"projects": "Campagnes",
|
||||
"reviewQueue": "File de révision",
|
||||
"content": "Contenu",
|
||||
"profile": "Profil",
|
||||
"signIn": "Se connecter",
|
||||
"settings": "Paramètres",
|
||||
"language": "Langue",
|
||||
"signOut": "Se déconnecter",
|
||||
"noWorkspace": "Aucun espace"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"unread": "non lues",
|
||||
"loading": "Chargement des notifications...",
|
||||
"empty": "Aucune notification de workflow pour le moment.",
|
||||
"events": {
|
||||
"approvalRequested": "Approbation demandée",
|
||||
"approvalDecisionRecorded": "Décision d'approbation enregistrée",
|
||||
"commentCreated": "Commentaire ajouté",
|
||||
"commentResolved": "Commentaire résolu",
|
||||
"contentCreated": "Élément de contenu créé",
|
||||
"revisionCreated": "Révision créée",
|
||||
"statusUpdated": "Statut mis à jour",
|
||||
"assetLinked": "Ressource liée",
|
||||
"assetRevisionCreated": "Révision de ressource créée"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"allClients": "Tous les clients",
|
||||
"allChannels": "Tous les canaux",
|
||||
"allProjects": "Toutes les campagnes",
|
||||
"allReviewItems": "File de révision complète",
|
||||
"noClients": "Aucun client pour le moment.",
|
||||
"noChannels": "Aucun canal pour le moment.",
|
||||
"noProjects": "Aucune campagne pour le moment.",
|
||||
"noReviewItems": "Aucun élément à réviser pour le moment."
|
||||
},
|
||||
"settings": {
|
||||
"eyebrow": "Paramètres",
|
||||
"title": "Paramètres du compte",
|
||||
"userInformation": "Informations utilisateur",
|
||||
"workspaces": "Espaces de travail",
|
||||
"integrations": "Intégrations"
|
||||
},
|
||||
"dashboard": {
|
||||
"eyebrow": "Calendrier de l'espace",
|
||||
"title": "Calendrier",
|
||||
"description": "Voyez ce qui est prévu pour une journée donnée et consultez l'agenda des publications dans l'ordre.",
|
||||
"workspaceLabel": "Espace actif",
|
||||
"loading": "Chargement des données de l'espace...",
|
||||
"calendarKicker": "Agenda du jour",
|
||||
"executionKicker": "À venir",
|
||||
"riskKicker": "Risque de livraison",
|
||||
"reviewKicker": "État des révisions",
|
||||
"upcomingContent": "Contenu à venir",
|
||||
"deliveryRisks": "Ce qui peut glisser",
|
||||
"overdueItems": "Éléments en retard",
|
||||
"approvalBlockers": "En attente d'approbation ou de révision",
|
||||
"unscheduledProjects": "Campagnes sans contenu planifié",
|
||||
"reviewQueueSnapshot": "Aperçu de la file de révision",
|
||||
"emptyUpcoming": "Aucun contenu planifié à venir.",
|
||||
"emptyOverdue": "Rien n'est en retard pour le moment.",
|
||||
"emptyApproval": "Aucun blocage d'approbation pour le moment.",
|
||||
"emptyProjects": "Chaque campagne a au moins un élément de contenu planifié.",
|
||||
"emptyReviewQueue": "Aucun élément actif dans la file de révision.",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"today": "Aujourd'hui",
|
||||
"month": "Mois",
|
||||
"week": "Semaine",
|
||||
"campaignDeadline": "Échéance de campagne",
|
||||
"emptyPeriod": "Aucun élément planifié.",
|
||||
"daySummary": "{content} contenus · {projects} échéances de campagne",
|
||||
"moreItems": "+{count} autres",
|
||||
"emptyDayAgenda": "Aucun contenu n'est planifié pour cette journée.",
|
||||
"projectProgress": "{scheduled} planifiés · {approved} approuvés",
|
||||
"missingSchedule": "Contenu à planifier",
|
||||
"noDueDate": "Aucune échéance",
|
||||
"labels": {
|
||||
"unassignedProject": "Campagne non attribuée"
|
||||
},
|
||||
"readiness": {
|
||||
"building": "En production",
|
||||
"approval": "En attente d'approbation",
|
||||
"rework": "Révision requise",
|
||||
"ready": "Prêt à publier",
|
||||
"published": "Publié",
|
||||
"blocked": "Bloqué",
|
||||
"archived": "Archivé",
|
||||
"scheduled": "Planifié",
|
||||
"missing": "Aucun contenu planifié"
|
||||
},
|
||||
"stats": {
|
||||
"scheduledThisDay": "Planifiés ce jour",
|
||||
"overdue": "En retard",
|
||||
"awaitingApproval": "En attente d'approbation",
|
||||
"readyToShip": "Prêts à livrer"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"eyebrow": "Vue portefeuille",
|
||||
"title": "Chronologie multi-espaces",
|
||||
"description": "Suivez les livraisons à venir, les risques et l'activité sur tous les espaces auxquels vous avez accès.",
|
||||
"loading": "Chargement des données globales...",
|
||||
"workspacesKicker": "Périmètre d'accès",
|
||||
"workspaceRollup": "Synthèse des espaces",
|
||||
"timelineKicker": "À venir",
|
||||
"upcomingTitle": "Planifié sur tous les espaces",
|
||||
"riskKicker": "À surveiller",
|
||||
"risksTitle": "Éléments déjà à risque",
|
||||
"activityKicker": "Activité récente",
|
||||
"activityTitle": "Derniers événements du workflow",
|
||||
"emptyUpcoming": "Aucun élément planifié à venir sur vos espaces.",
|
||||
"emptyRisks": "Aucun risque de livraison multi-espace pour le moment.",
|
||||
"emptyActivity": "Aucune activité récente du workflow.",
|
||||
"labels": {
|
||||
"projects": "campagnes",
|
||||
"upcoming": "à venir",
|
||||
"blocked": "bloqués"
|
||||
},
|
||||
"stats": {
|
||||
"workspaces": "Espaces",
|
||||
"projects": "Campagnes",
|
||||
"upcoming": "Éléments à venir",
|
||||
"blockers": "Éléments à risque"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"eyebrow": "Gestion client",
|
||||
"title": "Clients",
|
||||
"description": "Comptes clients, identité de marque et contacts principaux d'approbation.",
|
||||
"newClient": "Nouveau client",
|
||||
"createTitle": "Créer un client",
|
||||
"loading": "Chargement des clients...",
|
||||
"empty": "Aucun client n'est disponible pour l'espace actif.",
|
||||
"noPrimaryContact": "Aucun contact principal défini",
|
||||
"noPrimaryContactEmail": "Aucun email de contact principal défini",
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du client est requis.",
|
||||
"createFailed": "Le client n'a pas pu être créé."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom du client",
|
||||
"portraitUrl": "URL du logo ou portrait du client",
|
||||
"primaryContactName": "Nom du contact principal",
|
||||
"primaryContactEmail": "Email du contact principal",
|
||||
"primaryContactPortraitUrl": "URL du portrait du contact principal"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"eyebrow": "Planification des campagnes",
|
||||
"title": "Campagnes",
|
||||
"description": "Campagnes regroupées dans l'espace actif par statut, plage de dates et notes de planification.",
|
||||
"newProject": "Nouvelle campagne",
|
||||
"createTitle": "Créer une campagne",
|
||||
"loading": "Chargement des campagnes...",
|
||||
"empty": "Aucune campagne n'est disponible pour l'espace actif.",
|
||||
"unknownClient": "Client inconnu",
|
||||
"noDateRange": "Aucune plage de dates",
|
||||
"errors": {
|
||||
"required": "Le nom de la campagne et la plage de dates sont requis.",
|
||||
"invalidDateRange": "La date de fin doit être postérieure ou égale à la date de début.",
|
||||
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer des campagnes.",
|
||||
"createFailed": "La campagne n'a pas pu être créée."
|
||||
},
|
||||
"fields": {
|
||||
"client": "Client",
|
||||
"selectClient": "Sélectionner un client",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"name": "Nom de la campagne",
|
||||
"description": "Description",
|
||||
"notes": "Notes"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"title": "Canaux",
|
||||
"description": "Ajoutez des canaux à l'espace.",
|
||||
"createTitle": "Créer un canal",
|
||||
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
|
||||
"emptyAction": "Ajouter un canal pour {network}",
|
||||
"nextDue": "Prochaine échéance",
|
||||
"noScheduled": "Rien de planifié",
|
||||
"fields": {
|
||||
"name": "Nom du canal",
|
||||
"network": "Réseau"
|
||||
},
|
||||
"metrics": {
|
||||
"scheduled": "Planifié",
|
||||
"ready": "Prêt",
|
||||
"blocked": "Bloqué"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "Le canal n'a pas pu être créé."
|
||||
}
|
||||
},
|
||||
"reviewQueue": {
|
||||
"eyebrow": "Flux de révision",
|
||||
"title": "File de révision",
|
||||
"description": "Approbations, révisions et demandes de changement en attente pour l'espace actif.",
|
||||
"empty": "Aucun élément de révision n'est disponible pour l'espace actif."
|
||||
},
|
||||
"contentItems": {
|
||||
"eyebrow": "Flux de contenu",
|
||||
"title": "Éléments de contenu",
|
||||
"description": "Unités révisables avec ressources, texte et statut d'approbation dans l'espace actif.",
|
||||
"newItem": "Nouvel élément de contenu",
|
||||
"createTitle": "Créer un élément de contenu",
|
||||
"loading": "Chargement des éléments de contenu...",
|
||||
"empty": "Aucun élément de contenu n'est disponible pour l'espace actif.",
|
||||
"noDueDate": "Aucune échéance",
|
||||
"assetsHelper": "Les ressources Google Drive sont maintenant liées depuis la page de détail de l'élément après sa création.",
|
||||
"errors": {
|
||||
"required": "Le titre, la campagne, le message et les cibles sont requis.",
|
||||
"workspaceAccountRequired": "Cet espace a besoin d'un compte opérationnel avant de créer du contenu.",
|
||||
"createFailed": "L'élément de contenu n'a pas pu être créé."
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"client": "Client",
|
||||
"selectClient": "Sélectionner un client",
|
||||
"project": "Campagne",
|
||||
"selectProject": "Sélectionner une campagne",
|
||||
"dueDate": "Date d'échéance",
|
||||
"publicationTargets": "Cibles de publication",
|
||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
||||
"publicationMessage": "Message de publication",
|
||||
"hashtags": "Hashtags",
|
||||
"hashtagsPlaceholder": "#lancement #marque #campagne",
|
||||
"assets": "Ressources"
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
"eyebrow": "Informations utilisateur",
|
||||
"title": "Profil et identité",
|
||||
"description": "Gérez le portrait et les informations du compte affichés dans l'espace.",
|
||||
"updatePortrait": "Mettre à jour le portrait",
|
||||
"accountDetails": "Détails du compte",
|
||||
"accountDetailsDescription": "Des champs supplémentaires d'édition du compte peuvent être ajoutés ici ensuite.",
|
||||
"alias": "Alias",
|
||||
"fullName": "Nom complet",
|
||||
"email": "Email",
|
||||
"noEmail": "Aucun email défini",
|
||||
"cropperTitle": "Mettre à jour le portrait utilisateur",
|
||||
"savePortrait": "Enregistrer le portrait",
|
||||
"choosePortrait": "Choisir un portrait"
|
||||
},
|
||||
"workspaceSettings": {
|
||||
"eyebrow": "Paramètres",
|
||||
"title": "Paramètres de l'espace",
|
||||
"description": "Configurez l'espace courant avec les sections general, membres, workflow et connecteurs.",
|
||||
"currentWorkspace": "Espace actuel",
|
||||
"noWorkspaceSelected": "Aucun espace sélectionné",
|
||||
"activeWorkspace": "Espace actif",
|
||||
"contextNote": "Ces paramètres s'appliquent uniquement à l'espace courant.",
|
||||
"inviteTitle": "Inviter des membres",
|
||||
"inviteDescription": "Invitez des clients, sous-traitants ou collègues dans l'espace actif.",
|
||||
"inviteEmpty": "Aucune invitation en attente pour cet espace.",
|
||||
"sendInvite": "Envoyer l'invitation",
|
||||
"reset": "Réinitialiser",
|
||||
"errors": {
|
||||
"required": "Tous les champs de l'espace sont requis.",
|
||||
"createFailed": "L'espace n'a pas pu être créé.",
|
||||
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
|
||||
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom de l'espace",
|
||||
"slug": "Slug de l'espace",
|
||||
"timeZone": "Fuseau horaire",
|
||||
"memberEmail": "Email du membre",
|
||||
"memberRole": "Rôle"
|
||||
},
|
||||
"roles": {
|
||||
"administrator": "Administrateur",
|
||||
"manager": "Gestionnaire",
|
||||
"client": "Réviseur client",
|
||||
"provider": "Sous-traitant",
|
||||
"workspaceMember": "Membre de l'espace"
|
||||
},
|
||||
"summary": {
|
||||
"name": "Nom",
|
||||
"slug": "Slug",
|
||||
"timeZone": "Fuseau horaire",
|
||||
"created": "Créé"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Général",
|
||||
"members": "Membres",
|
||||
"workflow": "Workflow",
|
||||
"connectors": "Connecteurs"
|
||||
},
|
||||
"members": {
|
||||
"inviteTitle": "Inviter",
|
||||
"activeTitle": "Membres",
|
||||
"activeDescription": "Voyez toutes les personnes qui appartiennent actuellement à l'espace actif.",
|
||||
"activeEmpty": "Aucun membre trouvé pour cet espace.",
|
||||
"pendingTitle": "Invitations en attente",
|
||||
"pendingDescription": "Suivez les personnes invitées dans l'espace actif."
|
||||
},
|
||||
"connectors": {
|
||||
"title": "Connecteurs",
|
||||
"description": "Gerez les connecteurs au niveau de l'espace qui alimentent des fonctions comme la bibliotheque media.",
|
||||
"openMediaLibrary": "Ouvrir la bibliotheque media",
|
||||
"googleDrive": {
|
||||
"title": "Google Drive",
|
||||
"description": "Ce connecteur doit alimenter la synchronisation media de l'espace pour les images, videos et autres fichiers partages.",
|
||||
"status": "Configuration en attente"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"summaryTitle": "Résumé de l'espace",
|
||||
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
|
||||
},
|
||||
"approvals": {
|
||||
"flowTitle": "Flux d'approbation",
|
||||
"flowDescription": "Personnalisez le passage du contenu par la révision interne, la révision client et la mise en publication pour cet espace.",
|
||||
"previewTitle": "Aperçu du flux",
|
||||
"previewDescription": "Voici la séquence que l'espace utilisera selon la configuration actuelle.",
|
||||
"saved": "Le flux d'approbation a été enregistré pour cet espace dans ce navigateur.",
|
||||
"fields": {
|
||||
"requireInternalReview": "Exiger une révision interne",
|
||||
"internalApproversRequired": "Approbateurs internes requis",
|
||||
"requireClientReview": "Exiger une révision client",
|
||||
"clientApproversRequired": "Approbateurs client requis",
|
||||
"defaultReviewerRole": "Rôle du réviseur par défaut",
|
||||
"publishBehaviour": "Après l'approbation finale"
|
||||
},
|
||||
"fieldHelp": {
|
||||
"requireInternalReview": "Le contenu doit être approuvé en interne avant de passer à la révision client.",
|
||||
"requireClientReview": "Le contenu doit encore passer par une approbation client avant la publication."
|
||||
},
|
||||
"publishBehaviour": {
|
||||
"manual": "Marquer prêt à publier",
|
||||
"auto": "Passer automatiquement à prêt"
|
||||
},
|
||||
"steps": {
|
||||
"internal": "Révision interne",
|
||||
"client": "Révision client",
|
||||
"publish": "Passage à la publication"
|
||||
},
|
||||
"stepDetail": {
|
||||
"approverCount": "{count} approbateur(s) requis",
|
||||
"autoPublish": "Le contenu passe automatiquement à prêt après l'approbation finale.",
|
||||
"manualPublish": "Le contenu reste dans une étape manuelle prêt à publier après l'approbation finale."
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"eyebrow": "Intégrations",
|
||||
"title": "Google Drive et clés API",
|
||||
"description": "C'est ici que doivent vivre les intégrations au niveau de l'espace et la configuration des identifiants.",
|
||||
"statusLabel": "Statut",
|
||||
"pendingTitle": "Interface de configuration en attente",
|
||||
"googleDrive": {
|
||||
"title": "Google Drive",
|
||||
"description": "Configurez la connexion de l'espace utilisée pour la liaison des ressources et l'entrée des révisions.",
|
||||
"nextStep": "Prochaine étape : ajouter des paramètres d'intégration stockés pour l'espace et les connecter au flux de liaison des ressources."
|
||||
},
|
||||
"apiKeys": {
|
||||
"title": "Clés API",
|
||||
"description": "Les secrets de l'espace et identifiants de services externes doivent être gérés ici.",
|
||||
"nextStep": "Prochaine étape : ajouter une persistance backend sécurisée et une gestion masquée des clés."
|
||||
}
|
||||
},
|
||||
"mediaLibrary": {
|
||||
"eyebrow": "Bibliotheque media",
|
||||
"title": "Medias de l'espace",
|
||||
"description": "Gerez la bibliotheque partagee d'images et de videos qui devrait se synchroniser avec Google Drive pour cet espace.",
|
||||
"syncCard": {
|
||||
"title": "Synchronisation Google Drive",
|
||||
"description": "Cette zone servira a connecter le Drive de l'espace, importer les medias approuves et garder la bibliotheque alignee sur les dossiers externes."
|
||||
},
|
||||
"mediaTypesTitle": "Medias pris en charge",
|
||||
"mediaTypesDescription": "La bibliotheque doit devenir l'endroit unique pour parcourir les ressources visuelles avant de les lier au contenu.",
|
||||
"mediaTypes": {
|
||||
"images": "Images, visuels graphiques et elements de marque",
|
||||
"videos": "Videos, reels et exports en mouvement"
|
||||
},
|
||||
"workflowTitle": "Flux prevu",
|
||||
"workflowDescription": "Cette page est le point d'entree prevu pour le flux de synchronisation Google Drive dont on a parle.",
|
||||
"workflow": {
|
||||
"connectDrive": "Connecter la source Google Drive de l'espace.",
|
||||
"syncAssets": "Synchroniser les images et videos dans la bibliotheque interne.",
|
||||
"organizeLibrary": "Reviser, etiqueter et reutiliser les medias depuis un seul endroit au niveau de l'espace."
|
||||
},
|
||||
"statusLabel": "Statut",
|
||||
"pendingTitle": "Interface de gestion en attente",
|
||||
"pendingDescription": "L'entree de navigation et la page sont en place. La prochaine etape est de brancher la vraie synchro Drive, le listing, les filtres et les actions sur les ressources."
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "Une erreur inattendue s'est produite",
|
||||
"imageLoad": "Erreur lors du chargement de l'image",
|
||||
"imageUpload": "Erreur lors du téléchargement de l'image"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,18 @@ import {
|
||||
import vueGoogleOauth from 'vue3-google-login';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import Toast, { POSITION } from 'vue-toastification';
|
||||
import 'vue-toastification/dist/index.css';
|
||||
import './assets/main.css';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from '@/locales/en.json';
|
||||
import fr from '@/locales/fr.json';
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
|
||||
import { useReviewQueueStore } from '@/stores/reviewQueueStore.js';
|
||||
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
|
||||
import { useClientsStore } from '@/stores/clientsStore.js';
|
||||
import { useProjectsStore } from '@/stores/projectsStore.js';
|
||||
import { useNotificationsStore } from '@/stores/notificationsStore.js';
|
||||
import { useChannelsStore } from '@/stores/channelsStore.js';
|
||||
import { i18n } from '@/plugins/i18n.js';
|
||||
import config from '@/config.js';
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components: {
|
||||
@@ -51,15 +56,6 @@ const vuetify = createVuetify({
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
fallbackLocale: 'fr',
|
||||
messages: {
|
||||
en: en,
|
||||
fr: fr,
|
||||
},
|
||||
});
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -68,7 +64,7 @@ const app = createApp(App)
|
||||
.use(router)
|
||||
.use(i18n)
|
||||
.use(vueGoogleOauth, {
|
||||
clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
||||
clientId: config.googleClientId,
|
||||
})
|
||||
.use(Toast, {
|
||||
position: POSITION.TOP_CENTER,
|
||||
@@ -76,6 +72,12 @@ const app = createApp(App)
|
||||
|
||||
useAuthStore();
|
||||
useUserProfileStore();
|
||||
useCreatorProfileStore();
|
||||
useWorkspaceStore();
|
||||
useClientsStore();
|
||||
useProjectsStore();
|
||||
useChannelsStore();
|
||||
useReviewQueueStore();
|
||||
useContentItemsStore();
|
||||
useNotificationsStore();
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import config from '@/config.js';
|
||||
|
||||
export function useClient() {
|
||||
if (!import.meta.env.VITE_API_URL) {
|
||||
throw new Error('VITE_API_URL is not provided');
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
12
frontend/src/plugins/i18n.js
Normal file
12
frontend/src/plugins/i18n.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from '@/locales/en.json';
|
||||
import fr from '@/locales/fr.json';
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
fr,
|
||||
},
|
||||
});
|
||||
@@ -1,115 +1,147 @@
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import CreatorHome from '@/views/creators/CreatorHome.vue';
|
||||
import CreatorLayout from '@/views/creators/CreatorLayout.vue';
|
||||
|
||||
const LoginView = () => import('@/views/auth/LoginView.vue');
|
||||
|
||||
const About = () => import('@/views/documentation/About.vue');
|
||||
const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue');
|
||||
const CreatorGuide = () => import('@/views/documentation/CreatorGuide.vue');
|
||||
const DocumentationLayout = () => import('@/views/documentation/DocumentationLayout.vue');
|
||||
const FAQ = () => import('@/views/documentation/FAQ.vue');
|
||||
const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue');
|
||||
const Pricing = () => import('@/views/documentation/Pricing.vue');
|
||||
const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue');
|
||||
const ProfilePage = () => import('@/views/profile/ProfilePage.vue');
|
||||
const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue');
|
||||
const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue');
|
||||
const Landing = () => import('@/views/main/Landing.vue');
|
||||
|
||||
const CreateCreator = () => import('@/views/creators/CreateCreator.vue');
|
||||
const RegisterView = () => import('@/views/auth/RegisterView.vue');
|
||||
const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue');
|
||||
const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue');
|
||||
const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue');
|
||||
const OverviewView = () => import('@/views/app/OverviewView.vue');
|
||||
const DashboardView = () => import('@/views/app/DashboardView.vue');
|
||||
const ChannelsView = () => import('@/views/app/ChannelsView.vue');
|
||||
const CampaignsView = () => import('@/views/app/ProjectsView.vue');
|
||||
const CampaignDetailView = () => import('@/views/app/ProjectDetailView.vue');
|
||||
const MediaLibraryView = () => import('@/views/app/MediaLibraryView.vue');
|
||||
const WorkspaceCreateView = () => import('@/views/app/WorkspaceCreateView.vue');
|
||||
const SettingsLayoutView = () => import('@/views/app/SettingsLayoutView.vue');
|
||||
const UserSettingsView = () => import('@/views/app/UserSettingsView.vue');
|
||||
const IntegrationsSettingsView = () => import('@/views/app/IntegrationsSettingsView.vue');
|
||||
const WorkspaceSettingsView = () => import('@/views/app/WorkspaceSettingsView.vue');
|
||||
const ReviewQueueView = () => import('@/views/app/ReviewQueueView.vue');
|
||||
const ContentItemsView = () => import('@/views/app/ContentItemsView.vue');
|
||||
const ContentItemDetailView = () => import('@/views/app/ContentItemDetailView.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/landing',
|
||||
path: '/',
|
||||
name: 'landing',
|
||||
component: Landing,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'landing' },
|
||||
path: '/app',
|
||||
redirect: { name: 'dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/@:creator',
|
||||
component: CreatorLayout,
|
||||
path: '/app/dashboard',
|
||||
name: 'dashboard',
|
||||
component: OverviewView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/workspace',
|
||||
name: 'workspace-dashboard',
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/channels',
|
||||
name: 'channels',
|
||||
component: ChannelsView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/media-library',
|
||||
name: 'media-library',
|
||||
component: MediaLibraryView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/campaigns',
|
||||
name: 'campaigns',
|
||||
component: CampaignsView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/campaigns/:projectId',
|
||||
name: 'campaign-detail',
|
||||
component: CampaignDetailView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/reviews',
|
||||
name: 'review-queue',
|
||||
component: ReviewQueueView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/workspace-settings',
|
||||
name: 'workspace-settings',
|
||||
component: WorkspaceSettingsView,
|
||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
||||
},
|
||||
{
|
||||
path: '/app/workspaces/new',
|
||||
name: 'workspace-create',
|
||||
component: WorkspaceCreateView,
|
||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
||||
},
|
||||
{
|
||||
path: '/app/settings',
|
||||
component: SettingsLayoutView,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'creator',
|
||||
component: CreatorHome,
|
||||
redirect: { name: 'settings-user-information' },
|
||||
},
|
||||
{
|
||||
path: 'tip-completed',
|
||||
name: 'PaymentCompleted',
|
||||
component: PaymentCompleted,
|
||||
path: 'user-information',
|
||||
name: 'settings-user-information',
|
||||
component: UserSettingsView,
|
||||
},
|
||||
{
|
||||
path: 'tip-cancelled',
|
||||
name: 'PaymentFailed',
|
||||
component: PaymentFailed,
|
||||
path: 'workspaces',
|
||||
name: 'settings-workspaces',
|
||||
component: WorkspaceSettingsView,
|
||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'settings-integrations',
|
||||
component: IntegrationsSettingsView,
|
||||
meta: { requiresAuth: true, roles: ['Administrator', 'Manager'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
component: DocumentationLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'helpandcontact',
|
||||
name: 'helpandcontact',
|
||||
component: HelpAndContact,
|
||||
},
|
||||
{
|
||||
path: 'termsandconditions',
|
||||
name: 'termsandconditions',
|
||||
component: TermsAndConditions,
|
||||
},
|
||||
{
|
||||
path: 'contentpolicy',
|
||||
name: 'contentpolicy',
|
||||
component: ContentPolicy,
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'FAQ',
|
||||
component: FAQ,
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: About,
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'pricing',
|
||||
component: Pricing,
|
||||
},
|
||||
],
|
||||
path: '/app/content',
|
||||
name: 'content-items',
|
||||
component: ContentItemsView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/content/new',
|
||||
name: 'content-item-create',
|
||||
component: ContentItemDetailView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/content/:id',
|
||||
name: 'content-item-detail',
|
||||
component: ContentItemDetailView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { notAuthenticated: true },
|
||||
props: route => ({ returnUrl: route.query.returnUrl || '/landing' }),
|
||||
props: route => ({ returnUrl: route.query.returnUrl || '/app/dashboard' }),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: ProfilePage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/create-creator',
|
||||
name: 'create-creator',
|
||||
component: CreateCreator,
|
||||
meta: { requiresAuth: true },
|
||||
redirect: { name: 'dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
@@ -136,6 +168,10 @@ const routes = [
|
||||
component: VerifyEmailView,
|
||||
meta: { notAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: { name: 'landing' },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -154,10 +190,16 @@ router.beforeEach((to, from, next) => {
|
||||
query: { returnUrl: to.fullPath },
|
||||
});
|
||||
} else {
|
||||
const requiredRoles = to.matched.flatMap(record => record.meta.roles ?? []);
|
||||
if (requiredRoles.length > 0 && !authStore.hasAnyRole(requiredRoles)) {
|
||||
next({ name: 'dashboard' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
} else if (to.matched.some(record => record.meta.notAuthenticated)) {
|
||||
if (authStore.isAuthenticated) next({ name: 'landing' });
|
||||
if (authStore.isAuthenticated) next({ name: 'dashboard' });
|
||||
else next();
|
||||
} else {
|
||||
next();
|
||||
|
||||
@@ -24,6 +24,20 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
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) {
|
||||
@@ -259,11 +273,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {useClient} from "@/plugins/api.js";
|
||||
import {useSessionStorage} from "@vueuse/core";
|
||||
import {ref, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
||||
export const useBrandingStore = defineStore(
|
||||
'branding',
|
||||
() => {
|
||||
|
||||
const currentBrand = ref(undefined)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const notFound = ref(false)
|
||||
|
||||
const value = useSessionStorage(
|
||||
'branding',
|
||||
{},
|
||||
{writeDefaults: false})
|
||||
|
||||
const presentationInfos = ref([])
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.params.creator,
|
||||
async (creator) => {
|
||||
// Extract just the creator name from the path (remove any additional segments)
|
||||
const creatorName = creator ? creator.split('/')[0] : undefined;
|
||||
await updateBrand(creatorName);
|
||||
}
|
||||
)
|
||||
|
||||
async function updateBrand(newBrand) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
notFound.value = false
|
||||
|
||||
if (newBrand !== currentBrand.value) {
|
||||
if (newBrand !== undefined) {
|
||||
const result = await fetchCreatorData(newBrand)
|
||||
if (result.success) {
|
||||
value.value = result.data
|
||||
currentBrand.value = newBrand
|
||||
presentationInfos.value = result.data?.presentationInfos
|
||||
} else {
|
||||
// Handle different error types
|
||||
if (result.status === 404) {
|
||||
notFound.value = true
|
||||
error.value = 'Creator not found'
|
||||
} else {
|
||||
error.value = result.error || 'Failed to load creator'
|
||||
}
|
||||
value.value = {}
|
||||
currentBrand.value = undefined
|
||||
presentationInfos.value = []
|
||||
}
|
||||
} else {
|
||||
value.value = {}
|
||||
currentBrand.value = undefined
|
||||
presentationInfos.value = []
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const fetchCreatorData = async (creatorAlias) => {
|
||||
try {
|
||||
const client = useClient()
|
||||
const response = await client.get(`/api/creators/@${creatorAlias}`)
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
console.error('Error fetching creator data:', error)
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
return { success: false, status: 404, error: 'Creator not found' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: error.response?.status || 500,
|
||||
error: error.message || 'Unknown error occurred'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentBrand,
|
||||
value,
|
||||
loading,
|
||||
error,
|
||||
notFound,
|
||||
updateBrand,
|
||||
presentationInfos
|
||||
}
|
||||
})
|
||||
122
frontend/src/stores/channelsStore.js
Normal file
122
frontend/src/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 '@/stores/workspaceStore.js';
|
||||
import { useContentItemsStore } from '@/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,
|
||||
};
|
||||
});
|
||||
182
frontend/src/stores/clientsStore.js
Normal file
182
frontend/src/stores/clientsStore.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/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,
|
||||
};
|
||||
});
|
||||
255
frontend/src/stores/contentItemDetailStore.js
Normal file
255
frontend/src/stores/contentItemDetailStore.js
Normal file
@@ -0,0 +1,255 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useWorkspaceStore } from '@/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/stores/contentItemsStore.js
Normal file
112
frontend/src/stores/contentItemsStore.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/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,
|
||||
};
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import {useClient} from '@/plugins/api.js';
|
||||
import {useAuthStore} from '@/stores/authStore.js';
|
||||
import {useSessionStorage} from '@vueuse/core';
|
||||
import {defineStore} from 'pinia';
|
||||
import {computed, watch} from 'vue';
|
||||
import {useRouter} from 'vue-router';
|
||||
|
||||
export const useCreatorProfileStore = defineStore(
|
||||
'creator-profile',
|
||||
() => {
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchCreatorProfile();
|
||||
if (value.value && value.value.name !== undefined) {
|
||||
await router.push(`/@${value.value.slug}`);
|
||||
} else {
|
||||
await router.push('/');
|
||||
}
|
||||
} else if (!authStore.isRefreshing) {
|
||||
value.value = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const value = useSessionStorage(
|
||||
'creator-profile',
|
||||
{},
|
||||
{
|
||||
writeDefaults: false,
|
||||
storage: window.sessionStorage,
|
||||
serializer: {
|
||||
read: (value) => value ? JSON.parse(value) : undefined,
|
||||
write: (value) => value ? JSON.stringify(value) : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const hasCreator = computed(
|
||||
() => value.value && Object.getOwnPropertyNames(value.value).length >= 1
|
||||
);
|
||||
|
||||
const client = useClient();
|
||||
|
||||
async function fetchCreatorProfile() {
|
||||
try {
|
||||
const response = await client.get(`/api/creators/profile`);
|
||||
value.value = response.data;
|
||||
} catch (error) {
|
||||
value.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCreatorPage() {
|
||||
try {
|
||||
await client.delete(`/api/creators/@${value.value.slug}`)
|
||||
await fetchCreatorProfile();
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCreatorPage() {
|
||||
try {
|
||||
await client.put(`/api/creators/@${value.value.slug}/restore`, {})
|
||||
await fetchCreatorProfile();
|
||||
}
|
||||
catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
creator: value,
|
||||
hasCreator,
|
||||
removeCreatorPage,
|
||||
restoreCreatorPage,
|
||||
fetchCreatorProfile
|
||||
};
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { i18n } from '@/plugins/i18n.js';
|
||||
|
||||
const ALLOWED_LOCALES = ['en', 'fr'];
|
||||
const DEFAULT_LOCALE = 'fr';
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
|
||||
export const useLanguageStore = defineStore('language', () => {
|
||||
const storedLocale = useSessionStorage('user-locale', DEFAULT_LOCALE);
|
||||
|
||||
// Get i18n instance (provided globally)
|
||||
const { locale } = useI18n();
|
||||
const locale = i18n.global.locale;
|
||||
|
||||
function sanitizeLocale(value) {
|
||||
return ALLOWED_LOCALES.includes(value) ? value : DEFAULT_LOCALE;
|
||||
@@ -18,15 +16,11 @@ export const useLanguageStore = defineStore('language', () => {
|
||||
// Initialize locale with a sanitized value
|
||||
const initial = sanitizeLocale(storedLocale.value);
|
||||
storedLocale.value = initial;
|
||||
if (locale) {
|
||||
locale.value = initial;
|
||||
}
|
||||
locale.value = initial;
|
||||
|
||||
function setLocale(newLocale) {
|
||||
const next = sanitizeLocale(newLocale);
|
||||
if (locale) {
|
||||
locale.value = next;
|
||||
}
|
||||
locale.value = next;
|
||||
storedLocale.value = next;
|
||||
}
|
||||
|
||||
|
||||
89
frontend/src/stores/notificationsStore.js
Normal file
89
frontend/src/stores/notificationsStore.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/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/stores/projectsStore.js
Normal file
99
frontend/src/stores/projectsStore.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/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,
|
||||
};
|
||||
});
|
||||
49
frontend/src/stores/reviewQueueStore.js
Normal file
49
frontend/src/stores/reviewQueueStore.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
|
||||
import { useProjectsStore } from '@/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,
|
||||
};
|
||||
});
|
||||
@@ -50,9 +50,15 @@ export const useUserProfileStore = defineStore(
|
||||
const portraitUrl = computed(() => {
|
||||
return value.value && value.value.portraitUrl
|
||||
? value.value.portraitUrl
|
||||
: '/images/usersmedia/anonyme/profilepictures/profileAnonymeSquare.png'
|
||||
: 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()
|
||||
@@ -153,7 +159,7 @@ export const useUserProfileStore = defineStore(
|
||||
try {
|
||||
const client = useClient()
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile)
|
||||
formData.append('file', selectedFile, selectedFile.name || 'portrait.png')
|
||||
|
||||
const response = await client.post(
|
||||
`/api/users/portrait`,
|
||||
@@ -170,6 +176,11 @@ export const useUserProfileStore = defineStore(
|
||||
alias,
|
||||
fullname,
|
||||
portraitUrl,
|
||||
roles,
|
||||
persona,
|
||||
authorizedWorkspaceIds,
|
||||
authorizedClientIds,
|
||||
authorizedProjectIds,
|
||||
changeFullname,
|
||||
changeAlias,
|
||||
changeBirthday,
|
||||
|
||||
208
frontend/src/stores/workspaceStore.js
Normal file
208
frontend/src/stores/workspaceStore.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/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,
|
||||
};
|
||||
});
|
||||
376
frontend/src/views/app/ChannelsView.vue
Normal file
376
frontend/src/views/app/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 '@/stores/workspaceStore.js';
|
||||
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
|
||||
import { useChannelsStore } from '@/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>
|
||||
712
frontend/src/views/app/ClientDetailView.vue
Normal file
712
frontend/src/views/app/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 '@/stores/authStore.js';
|
||||
import { useClientsStore } from '@/stores/clientsStore.js';
|
||||
import { useProjectsStore } from '@/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/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/views/app/ClientsView.vue
Normal file
366
frontend/src/views/app/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 '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
|
||||
import { useClientsStore } from '@/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>
|
||||
1201
frontend/src/views/app/ContentItemDetailView.vue
Normal file
1201
frontend/src/views/app/ContentItemDetailView.vue
Normal file
File diff suppressed because it is too large
Load Diff
146
frontend/src/views/app/ContentItemsView.vue
Normal file
146
frontend/src/views/app/ContentItemsView.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useContentItemsStore } from '@/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>
|
||||
620
frontend/src/views/app/DashboardView.vue
Normal file
620
frontend/src/views/app/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 '@/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/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>
|
||||
94
frontend/src/views/app/IntegrationsSettingsView.vue
Normal file
94
frontend/src/views/app/IntegrationsSettingsView.vue
Normal file
@@ -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>
|
||||
222
frontend/src/views/app/MediaLibraryView.vue
Normal file
222
frontend/src/views/app/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>
|
||||
418
frontend/src/views/app/OverviewView.vue
Normal file
418
frontend/src/views/app/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 '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/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>
|
||||
232
frontend/src/views/app/ProjectDetailView.vue
Normal file
232
frontend/src/views/app/ProjectDetailView.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/stores/projectsStore.js';
|
||||
import { useContentItemsStore } from '@/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/views/app/ProjectsView.vue
Normal file
376
frontend/src/views/app/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 '@/stores/authStore.js';
|
||||
import { useClientsStore } from '@/stores/clientsStore.js';
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
|
||||
import { useProjectsStore } from '@/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>
|
||||
102
frontend/src/views/app/ReviewQueueView.vue
Normal file
102
frontend/src/views/app/ReviewQueueView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useReviewQueueStore } from '@/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>
|
||||
93
frontend/src/views/app/SettingsLayoutView.vue
Normal file
93
frontend/src/views/app/SettingsLayoutView.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/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>
|
||||
163
frontend/src/views/app/UserSettingsView.vue
Normal file
163
frontend/src/views/app/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 '@/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>
|
||||
271
frontend/src/views/app/WorkspaceCreateView.vue
Normal file
271
frontend/src/views/app/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 '@/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/views/app/WorkspaceSettingsView.vue
Normal file
559
frontend/src/views/app/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 '@/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>
|
||||
@@ -1,686 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative p-4"
|
||||
@mouseenter="showEditButtons = isLoggedIn && creatorProfileStore.creator?.id === brandingStore.value.id"
|
||||
@mouseleave="showEditButtons = false"
|
||||
>
|
||||
<!-- Edit buttons with absolute positioning -->
|
||||
<div
|
||||
v-if="showEditButtons || isEditMode"
|
||||
class="absolute right-4 top-4 flex gap-2"
|
||||
>
|
||||
<!-- Edit button with pencil icon -->
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
:title="t('edit')"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
|
||||
@click="toggleEditMode()"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiPencil"
|
||||
large
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Save button -->
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
:disabled="isSaving || !canSave"
|
||||
:title="t('save')"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<template v-if="isSaving">
|
||||
<v-progress-circular
|
||||
color="white"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-icon :icon="mdiCheck" />
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<!-- Cancel button -->
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
:title="t('cancel')"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-red-500 shadow-lg"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiClose"
|
||||
large
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- MainPage -->
|
||||
<div class="flex flex-col">
|
||||
<h1 class="mb-4 flex justify-start text-center text-2xl font-bold">
|
||||
{{ t('creator.sections.about.title') }}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<!-- Description Section -->
|
||||
<div>
|
||||
<div v-if="!isEditMode">
|
||||
<p
|
||||
v-if="description"
|
||||
class="mb-6 whitespace-pre-line text-justify text-lg"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-if="isEditMode"
|
||||
v-model="editableDescription"
|
||||
:counter="2000"
|
||||
:error-messages="descriptionError"
|
||||
:label="t('creator.sections.about.description')"
|
||||
:rules="[
|
||||
v => !!v || t('creator.validation.descriptionRequired'),
|
||||
v => v.length <= 2000 || t('creator.validation.descriptionTooLong'),
|
||||
]"
|
||||
auto-grow
|
||||
class="w-full p-2 py-6"
|
||||
rows="5"
|
||||
variant="outlined"
|
||||
></v-textarea>
|
||||
</div>
|
||||
|
||||
<!-- Video Section -->
|
||||
<div
|
||||
v-if="videoUrl || isEditMode"
|
||||
:class="[
|
||||
'content-section',
|
||||
{
|
||||
'rounded-t-xl': hasImages && !isEditMode,
|
||||
'rounded-xl': !hasImages && !isEditMode,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="!isEditMode && videoUrl"
|
||||
class="video-container"
|
||||
>
|
||||
<iframe
|
||||
:src="youtubeEmbedUrl"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="video-frame"
|
||||
title="YouTube video player"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode">
|
||||
<v-text-field
|
||||
v-model="editableVideoUrl"
|
||||
:error-messages="videoUrlError"
|
||||
:label="t('creator.fields.videoUrl')"
|
||||
class="w-full p-2"
|
||||
type="text"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section using Album component -->
|
||||
<div>
|
||||
<!-- Use AlbumView for display mode -->
|
||||
<AlbumView
|
||||
v-if="!isEditMode && hasImages"
|
||||
:class="[
|
||||
'content-section',
|
||||
{
|
||||
'rounded-b-xl': videoUrl && !isEditMode,
|
||||
'rounded-xl': !videoUrl && !isEditMode,
|
||||
},
|
||||
]"
|
||||
:images="thumbnailUrls"
|
||||
@photo-click="handlePhotoClick"
|
||||
/>
|
||||
|
||||
<AlbumViewer
|
||||
v-model="showAlbumViewer"
|
||||
:images="originalUrls"
|
||||
:start-index="selectedPhotoIndex"
|
||||
/>
|
||||
|
||||
<!-- Use AlbumEditor for edit mode -->
|
||||
<AlbumEditor
|
||||
v-if="isEditMode"
|
||||
:images="photos"
|
||||
@update:images="updateImages"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information Section -->
|
||||
<div
|
||||
v-if="phoneNumber || email"
|
||||
class="contact-info mt-6"
|
||||
>
|
||||
<!-- Phone Number -->
|
||||
<div
|
||||
v-if="phoneNumber"
|
||||
class="contact-capsule"
|
||||
@click="callPhone"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiPhone"
|
||||
class="contact-icon"
|
||||
/>
|
||||
<span class="contact-text">{{ phoneNumber }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div
|
||||
v-if="email"
|
||||
class="contact-capsule"
|
||||
@click="sendEmail"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiEmail"
|
||||
class="contact-icon"
|
||||
/>
|
||||
<span class="contact-text">{{ email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { buildEmbedUrl, extractVideoId, isValidYouTubeUrlOrId } from '@/utils/youtube';
|
||||
import AlbumEditor from '@/views/creators/AlbumEditor.vue';
|
||||
import AlbumView from '@/views/creators/AlbumView.vue';
|
||||
import AlbumViewer from './AlbumViewer.vue';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import { mdiCheck, mdiClose, mdiEmail, mdiPencil, mdiPhone } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const brandingStore = useBrandingStore();
|
||||
const client = useClient();
|
||||
const toast = useToast();
|
||||
|
||||
// Fetch album data
|
||||
const isLoadingAlbum = ref(false);
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
const isLoggedIn = true;
|
||||
const isEditMode = ref(false);
|
||||
const showEditButtons = ref(false);
|
||||
|
||||
// Variables réactives pour les données
|
||||
const description = ref('');
|
||||
const videoUrl = ref('');
|
||||
const phoneNumber = ref('');
|
||||
const email = ref('');
|
||||
const photos = ref([]); //before was thumbnailUrls
|
||||
const albumId = ref(null);
|
||||
const originalPhotos = ref([]);
|
||||
// Add these refs with your other refs
|
||||
const showAlbumViewer = ref(false);
|
||||
const selectedPhotoIndex = ref(0);
|
||||
|
||||
// Editable fields
|
||||
const editableDescription = ref('');
|
||||
const editableVideoUrl = ref('');
|
||||
const videoUrlError = ref('');
|
||||
const descriptionError = ref('');
|
||||
|
||||
function callPhone() {
|
||||
if (phoneNumber.value) {
|
||||
toast.info('Calling your contact');
|
||||
// Remove formatting and create tel: link
|
||||
const cleanPhone = phoneNumber.value.replace(/\D/g, '');
|
||||
window.location.href = `tel:+1${cleanPhone}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendEmail() {
|
||||
if (email.value) {
|
||||
window.location.href = `mailto:${email.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property to check if we can save
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.value == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if description is empty or only whitespace
|
||||
if (!editableDescription.value || editableDescription.value.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if description is too long
|
||||
if (editableDescription.value.length > 2000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if video URL is invalid (if one is provided)
|
||||
if (editableVideoUrl.value && !validateVideoUrl(editableVideoUrl.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const thumbnailUrls = computed(() => {
|
||||
return photos.value.map(photo => photo.image.thumbnailUrl);
|
||||
});
|
||||
|
||||
// Add this computed property to get the original image URLs
|
||||
const originalUrls = computed(() => {
|
||||
return photos.value.map(photo => photo.image.originalUrl);
|
||||
});
|
||||
|
||||
// Computed property to check if there are images
|
||||
const hasImages = computed(() => {
|
||||
// Only consider it has images if there are actual image URLs (not empty strings)
|
||||
return photos.value.length > 0;
|
||||
});
|
||||
|
||||
// Computed property for YouTube embed URL
|
||||
const youtubeEmbedUrl = computed(() => {
|
||||
if (!videoUrl.value) return '';
|
||||
return buildEmbedUrl(videoUrl.value);
|
||||
});
|
||||
|
||||
// Validate video URL
|
||||
function validateVideoUrl(url) {
|
||||
if (!url) {
|
||||
videoUrlError.value = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isValidYouTubeUrlOrId(url)) {
|
||||
videoUrlError.value = t('creator.validation.invalidYoutubeUrl');
|
||||
return false;
|
||||
}
|
||||
|
||||
videoUrlError.value = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Watch for changes in editableVideoUrl
|
||||
watch(editableVideoUrl, newValue => {
|
||||
validateVideoUrl(newValue);
|
||||
});
|
||||
|
||||
// Activer/désactiver le mode édition
|
||||
function toggleEditMode() {
|
||||
isEditMode.value = !isEditMode.value;
|
||||
if (isEditMode.value) {
|
||||
// Charger les valeurs pour l'édition
|
||||
editableDescription.value = description.value;
|
||||
editableVideoUrl.value = videoUrl.value;
|
||||
videoUrlError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
id: brandingStore.value?.id,
|
||||
presentation: brandingStore.value?.presentation,
|
||||
}),
|
||||
async ({ id, presentation }, previousValue) => {
|
||||
// Only proceed if we have both id and presentation, and the id has changed
|
||||
if (id && presentation && id !== previousValue?.id) {
|
||||
console.log('Watcher triggered: Loading data for creator ID:', id);
|
||||
|
||||
// Load presentation data
|
||||
description.value = presentation.description || '';
|
||||
videoUrl.value = presentation.videoUrl || '';
|
||||
phoneNumber.value = presentation.phoneNumber || '';
|
||||
email.value = presentation.email || '';
|
||||
|
||||
// Fetch album data
|
||||
await fetchAlbumData();
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
async function fetchAlbumData() {
|
||||
if (isLoadingAlbum.value) {
|
||||
console.log('Album data already loading, skipping duplicate request');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('in fetchAlbumData()');
|
||||
if (!brandingStore.value?.id) return;
|
||||
|
||||
isLoadingAlbum.value = true;
|
||||
const creatorId = brandingStore.value.id;
|
||||
|
||||
try {
|
||||
// Try to get the album
|
||||
const response = await client.get(`/api/albums/${creatorId}`);
|
||||
|
||||
if (response.data && response.data.photos) {
|
||||
// Store original photos for comparison
|
||||
originalPhotos.value = response.data.photos;
|
||||
// Extract photo URLs from the album photos
|
||||
photos.value = response.data.photos.map(photo => ({
|
||||
file: null,
|
||||
image: photo,
|
||||
isProcessing: false,
|
||||
isUploading: false,
|
||||
}));
|
||||
albumId.value = creatorId;
|
||||
} else {
|
||||
// Initialize with an empty array instead of empty slots
|
||||
console.log('WOW! You found how to get here! Take a look at the stack!');
|
||||
photos.value = [];
|
||||
originalPhotos.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
photos.value = [];
|
||||
originalPhotos.value = [];
|
||||
} finally {
|
||||
isLoadingAlbum.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
if (!brandingStore.value?.presentation) return;
|
||||
|
||||
description.value = brandingStore.value.presentation.description || '';
|
||||
videoUrl.value = brandingStore.value.presentation.videoUrl || '';
|
||||
phoneNumber.value = brandingStore.value.presentation.phoneNumber || '';
|
||||
email.value = brandingStore.value.presentation.email || '';
|
||||
});
|
||||
|
||||
// Update images from Album component
|
||||
function updateImages(newImages) {
|
||||
photos.value = newImages;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!brandingStore.value?.id) {
|
||||
console.error("L'ID du créateur est manquant !");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate description is not empty
|
||||
if (!editableDescription.value || editableDescription.value.trim() === '') {
|
||||
descriptionError.value = t('creator.validation.descriptionRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate description length
|
||||
if (editableDescription.value.length > 2000) {
|
||||
descriptionError.value = t('creator.validation.descriptionTooLong');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate video URL before saving
|
||||
if (!validateVideoUrl(editableVideoUrl.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// Save presentation info
|
||||
const presentationResponse = await client.post(
|
||||
`/api/creators/${brandingStore.value.id}/presentation-infos`,
|
||||
{
|
||||
description: editableDescription.value || '',
|
||||
videoUrl: editableVideoUrl.value || null,
|
||||
}
|
||||
);
|
||||
|
||||
// Mettre à jour les valeurs locales pour refléter les changements
|
||||
description.value = editableDescription.value;
|
||||
videoUrl.value = extractVideoId(editableVideoUrl.value) || '';
|
||||
|
||||
// Check for deleted photos
|
||||
const photosOriginalUrls = photos.value.map(photo => photo.image.originalUrl);
|
||||
const deletedPhotos = originalPhotos.value.filter(originalPhoto => {
|
||||
// If the photo URL is not in the current images array, it was deleted
|
||||
return !photosOriginalUrls.includes(originalPhoto.originalUrl);
|
||||
});
|
||||
const newImages = photos.value.filter(
|
||||
photo => photo && photo.image && photo.image.originalUrl.startsWith('data:')
|
||||
);
|
||||
|
||||
console.log('originalPhotos', originalPhotos.value);
|
||||
console.log('photos', photos.value);
|
||||
console.log('deletedPhotos', deletedPhotos);
|
||||
console.log('newImages', newImages);
|
||||
|
||||
// Save album photos if they've changed
|
||||
if (photos.value.length > 0 || deletedPhotos.length > 0) {
|
||||
console.log('We got pending changes');
|
||||
|
||||
// Create the Album if we do not have one yet
|
||||
if (albumId.value == null) {
|
||||
console.log('We do not have an album yet');
|
||||
try {
|
||||
await client.post('/api/albums', {
|
||||
albumId: brandingStore.value.id,
|
||||
title: `${brandingStore.value.name}'s Album`,
|
||||
description: 'Photo album for the creator',
|
||||
});
|
||||
albumId.value = brandingStore.value.id;
|
||||
} catch (error) {
|
||||
// Album might already exist, which is fine
|
||||
console.log("Couldn't create an Album", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed photos
|
||||
for (const photo of deletedPhotos) {
|
||||
try {
|
||||
await client.delete(`/api/albums/${albumId.value}/photos/${photo.id}`);
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Now add or update photos
|
||||
for (let i = 0; i < newImages.length; i++) {
|
||||
const imageData = newImages[i];
|
||||
imageData.isUploading = true;
|
||||
console.log('Image Data to be uploaded:', imageData);
|
||||
// This is a new image that needs to be uploaded
|
||||
const photoId = crypto.randomUUID();
|
||||
|
||||
// Convert data URL to file
|
||||
const response = await fetch(imageData.image.originalUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], imageData.file.name, { type: imageData.file.type });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await client.post(`/api/albums/${albumId.value}/photos`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
params: {
|
||||
photoId: photoId,
|
||||
},
|
||||
});
|
||||
imageData.isUploading = false;
|
||||
}
|
||||
|
||||
// Refresh album data after changes
|
||||
await fetchAlbumData();
|
||||
}
|
||||
|
||||
isEditMode.value = false;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde :', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
// Restaurer les valeurs d'origine
|
||||
editableDescription.value = description.value;
|
||||
editableVideoUrl.value = videoUrl.value;
|
||||
|
||||
// Désactiver le mode édition
|
||||
isEditMode.value = false;
|
||||
}
|
||||
|
||||
// Add this function to handle photo clicks
|
||||
function handlePhotoClick(index) {
|
||||
selectedPhotoIndex.value = index;
|
||||
showAlbumViewer.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-section {
|
||||
@apply w-full overflow-hidden;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 31.25%;
|
||||
/* Reduced from 56.25% to make it shorter while maintaining aspect ratio */
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.video-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Add responsive breakpoints */
|
||||
@media (max-width: 640px) {
|
||||
.video-container {
|
||||
padding-top: 35%;
|
||||
max-height: 35vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.video-container {
|
||||
padding-top: 30%;
|
||||
max-height: 38vh;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.contact-capsule {
|
||||
@apply flex items-center gap-2 px-2 py-1 bg-hSurface;
|
||||
@apply rounded-xl cursor-pointer transition-all duration-200;
|
||||
@apply hover:shadow-md min-w-fit;
|
||||
@apply border border-hutopyPrimary;
|
||||
}
|
||||
|
||||
.contact-capsule:hover {
|
||||
@apply transform scale-105;
|
||||
}
|
||||
|
||||
.contact-icon {
|
||||
@apply text-hutopyPrimary;
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.contact-text {
|
||||
@apply text-hOnSurface font-medium text-base;
|
||||
}
|
||||
|
||||
/* Formatting styles for description */
|
||||
.text-justify {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Add some spacing between paragraphs */
|
||||
.text-justify p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"creator": {
|
||||
"sections": {
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Description",
|
||||
"contactInfo": "Contact Information",
|
||||
"characters": "characters",
|
||||
"formattingHint": "Tip: Use line breaks and emojis to make your description more engaging!"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
"image": "Image"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"videoUrl": "Video URL",
|
||||
"phoneNumber": "Phone Number",
|
||||
"email": "Email"
|
||||
},
|
||||
"validation": {
|
||||
"invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID",
|
||||
"descriptionTooLong": "Description cannot exceed 2000 characters",
|
||||
"descriptionRequired": "Description is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"edit": "Modifier",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"creator": {
|
||||
"sections": {
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"description": "Description",
|
||||
"contactInfo": "Informations de contact",
|
||||
"characters": "caractères",
|
||||
"formattingHint": "Astuce : Utilisez des sauts de ligne et des émojis pour rendre votre description plus attrayante !"
|
||||
},
|
||||
"photos": {
|
||||
"title": "Photos",
|
||||
"image": "Image"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"videoUrl": "URL de la vidéo",
|
||||
"phoneNumber": "Numéro de téléphone",
|
||||
"email": "Email"
|
||||
},
|
||||
"validation": {
|
||||
"invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide",
|
||||
"descriptionTooLong": "La description ne peut pas dépasser 2000 caractères",
|
||||
"descriptionRequired": "La description est obligatoire"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Banner Container with mouse events -->
|
||||
<div
|
||||
class="relative overflow-y-auto rounded-b-2xl"
|
||||
@click="isCurrentCreator && openBannerEditor()"
|
||||
@mouseenter="showTint = isCurrentCreator"
|
||||
@mouseleave="showTint = false"
|
||||
>
|
||||
<img
|
||||
:alt="t('alt')"
|
||||
:src="brandingStore.value?.bannerUrl ?? '/images/placeholders/banner.png'"
|
||||
class="banner aspect-[4/1] w-full object-cover"
|
||||
/>
|
||||
<!-- Tint Effect -->
|
||||
<div
|
||||
v-if="showTint"
|
||||
class="absolute inset-0 cursor-pointer bg-black/25"
|
||||
>
|
||||
<!-- Top-right Icon -->
|
||||
<div
|
||||
class="absolute right-4 top-4 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiPencil"
|
||||
large
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-dialog
|
||||
v-model="isDialogOpen"
|
||||
max-width="800px"
|
||||
>
|
||||
<BannerEditor
|
||||
:creator="brandingStore.value"
|
||||
@closeRequested="() => (isDialogOpen = false)"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BannerEditor from '@/views/creators/BannerEditor.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiPencil } from '@mdi/js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const brandingStore = useBrandingStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const showTint = ref(false);
|
||||
const isDialogOpen = ref(false);
|
||||
|
||||
// Methods
|
||||
const openBannerEditor = () => {
|
||||
isDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const isCurrentCreator = computed(() => {
|
||||
return authStore.userId === brandingStore.value.id;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"alt": "Creator banner"
|
||||
},
|
||||
"fr": {
|
||||
"alt": "Bannière du créateur"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,367 +0,0 @@
|
||||
<template>
|
||||
<div class="album-editor">
|
||||
<h2 class="mb-4 text-xl font-semibold">
|
||||
{{ t('title') }}
|
||||
</h2>
|
||||
|
||||
<!-- Drop zone with photos -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<!-- Upload prompt -->
|
||||
<div class="drop-zone-content">
|
||||
<v-icon
|
||||
:icon="mdiPlus"
|
||||
size="large"
|
||||
/>
|
||||
<span class="mt-2 text-sm">{{ t('dropzoneText') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
multiple
|
||||
type="file"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
|
||||
<!-- Photos grid -->
|
||||
<draggable
|
||||
v-model="localImages"
|
||||
:filter="'.action-btn'"
|
||||
:prevent-on-filter="false"
|
||||
class="photos-grid"
|
||||
item-key="id"
|
||||
@end="handleReorder"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="photo-wrapper">
|
||||
<div class="index-bubble">{{ index + 1 }}</div>
|
||||
<img
|
||||
:alt="'Image ' + (index + 1)"
|
||||
:src="element.image.originalUrl"
|
||||
/>
|
||||
<!-- Processing spinner overlay -->
|
||||
<div
|
||||
v-if="element.isProcessing"
|
||||
class="loading-overlay"
|
||||
>
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
></v-progress-circular>
|
||||
<span class="mt-2 text-sm text-white">{{ t('processing') }}</span>
|
||||
</div>
|
||||
<!-- Upload spinner overlay -->
|
||||
<div
|
||||
v-if="element.isUploading"
|
||||
class="loading-overlay uploading"
|
||||
>
|
||||
<v-progress-circular
|
||||
color="secondary"
|
||||
indeterminate
|
||||
></v-progress-circular>
|
||||
<span class="mt-2 text-sm text-white">{{ t('uploading') }}</span>
|
||||
</div>
|
||||
<!-- Left arrow -->
|
||||
<button
|
||||
:disabled="index === 0"
|
||||
:title="t('moveLeft')"
|
||||
class="action-btn left-btn"
|
||||
@click.stop="moveImage(index, 'up')"
|
||||
@touchstart.stop="moveImage(index, 'up')"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
</button>
|
||||
<!-- Right arrow -->
|
||||
<button
|
||||
:disabled="index === localImages.length - 1"
|
||||
:title="t('moveRight')"
|
||||
class="action-btn right-btn"
|
||||
@click.stop="moveImage(index, 'down')"
|
||||
@touchstart.stop="moveImage(index, 'down')"
|
||||
>
|
||||
<v-icon :icon="mdiArrowRight" />
|
||||
</button>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
:title="t('delete')"
|
||||
class="action-btn delete-btn"
|
||||
touchstart.stop="deleteImage(index)"
|
||||
@click.stop="deleteImage(index)"
|
||||
>
|
||||
<v-icon :icon="mdiDelete" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { v7 } from 'uuid';
|
||||
import draggable from 'vuedraggable';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiDelete, mdiPlus } from '@mdi/js';
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:images']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const fileInput = ref(null);
|
||||
const localImages = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize local images with IDs and states
|
||||
localImages.value = props.images;
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
console.log('handleFiles:', files);
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
// Create a temporary image object with processing state
|
||||
const tempImage = {
|
||||
image: {
|
||||
id: v7(),
|
||||
originalUrl: '',
|
||||
},
|
||||
file: file,
|
||||
isProcessing: true,
|
||||
isUploading: false,
|
||||
};
|
||||
localImages.value.push(tempImage);
|
||||
|
||||
console.log('Processing image:', tempImage);
|
||||
|
||||
reader.onload = e => {
|
||||
console.log('Image loaded:', e);
|
||||
const index = localImages.value.findIndex(local => local.image.id === tempImage.image.id);
|
||||
if (index !== -1) {
|
||||
localImages.value[index].image.originalUrl = e.target.result;
|
||||
localImages.value[index].isProcessing = false;
|
||||
emit('update:images', localImages.value);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing image:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
console.log('Drop triggered');
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
handleFiles(files);
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
console.log('Input triggered');
|
||||
fileInput.value.click();
|
||||
}
|
||||
|
||||
function handleFileUpload(event) {
|
||||
console.log('File input triggered');
|
||||
const files = Array.from(event.target.files);
|
||||
handleFiles(files);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function handleReorder() {
|
||||
emit('update:images', localImages.value);
|
||||
}
|
||||
|
||||
function moveImage(index, direction) {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex >= 0 && newIndex < localImages.value.length) {
|
||||
const temp = localImages.value[index];
|
||||
localImages.value[index] = localImages.value[newIndex];
|
||||
localImages.value[newIndex] = temp;
|
||||
emit('update:images', localImages.value);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteImage(index) {
|
||||
localImages.value.splice(index, 1);
|
||||
emit('update:images', localImages.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.album-editor {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
@apply w-full;
|
||||
@apply min-h-[200px];
|
||||
@apply border-2;
|
||||
@apply border-dashed;
|
||||
@apply border-gray-300 hover:border-gray-500;
|
||||
@apply rounded-lg;
|
||||
@apply p-4;
|
||||
@apply relative;
|
||||
@apply transition-colors;
|
||||
@apply duration-200;
|
||||
@apply overflow-visible;
|
||||
@apply bg-hSurface;
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-center;
|
||||
@apply text-gray-500;
|
||||
@apply mb-8;
|
||||
@apply relative;
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
.photos-grid {
|
||||
@apply grid;
|
||||
@apply grid-cols-2;
|
||||
@apply sm:grid-cols-3;
|
||||
@apply md:grid-cols-4;
|
||||
@apply lg:grid-cols-5;
|
||||
@apply gap-4;
|
||||
@apply w-full;
|
||||
@apply pb-1;
|
||||
}
|
||||
|
||||
.photo-wrapper {
|
||||
@apply relative;
|
||||
@apply aspect-square;
|
||||
@apply rounded-lg;
|
||||
@apply overflow-hidden;
|
||||
@apply bg-gray-100;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.photo-wrapper img {
|
||||
@apply w-full;
|
||||
@apply h-full;
|
||||
@apply object-cover;
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@apply absolute;
|
||||
@apply bg-black;
|
||||
@apply bg-opacity-50;
|
||||
@apply text-white;
|
||||
@apply rounded-full;
|
||||
@apply p-1;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply transition-all;
|
||||
@apply duration-200;
|
||||
@apply opacity-0;
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
/* Show buttons on hover for desktop */
|
||||
.action-btn {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
@apply opacity-30;
|
||||
@apply cursor-not-allowed;
|
||||
@apply bg-gray-500;
|
||||
@apply scale-90;
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
@apply top-1/2;
|
||||
@apply -translate-y-1/2;
|
||||
@apply left-2;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
@apply top-1/2;
|
||||
@apply -translate-y-1/2;
|
||||
@apply right-2;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
@apply top-2;
|
||||
@apply right-2;
|
||||
@apply bg-red-500;
|
||||
@apply bg-opacity-50;
|
||||
}
|
||||
|
||||
.index-bubble {
|
||||
@apply absolute;
|
||||
@apply top-2;
|
||||
@apply left-2;
|
||||
@apply bg-black;
|
||||
@apply bg-opacity-50;
|
||||
@apply text-white;
|
||||
@apply text-xs;
|
||||
@apply font-medium;
|
||||
@apply rounded-full;
|
||||
@apply w-6;
|
||||
@apply h-6;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
@apply absolute;
|
||||
@apply inset-0;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply bg-black;
|
||||
@apply bg-opacity-50;
|
||||
@apply z-20;
|
||||
}
|
||||
|
||||
.loading-overlay.uploading {
|
||||
@apply bg-opacity-75;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Album",
|
||||
"dropzoneText": "Drop a photo here to add it to your album",
|
||||
"processing": "Processing...",
|
||||
"uploading": "Uploading...",
|
||||
"moveLeft": "Move Left",
|
||||
"moveRight": "Move Right",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Album",
|
||||
"dropzoneText": "Déposez une photo ici pour l'ajouter à l'album",
|
||||
"processing": "Traitement en cours...",
|
||||
"uploading": "Téléchargement...",
|
||||
"moveLeft": "Déplacer à gauche",
|
||||
"moveRight": "Déplacer à droite",
|
||||
"delete": "Supprimer"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasImages"
|
||||
class="album-view"
|
||||
>
|
||||
<!-- Album Display -->
|
||||
<div class="image-grid">
|
||||
<div
|
||||
v-for="(url, index) in displayedImages"
|
||||
:key="index"
|
||||
class="image-wrapper"
|
||||
@click="$emit('photo-click', index)"
|
||||
>
|
||||
<img
|
||||
:alt="t('creator.sections.album.image')"
|
||||
:src="url"
|
||||
class="image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Add 'photo-click' to emits
|
||||
const emit = defineEmits(['photo-click']);
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
images: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Add a reactive window width
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
// Update window width on resize
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// Add and remove event listener
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
const hasImages = computed(() => {
|
||||
return props.images.some(url => url);
|
||||
});
|
||||
|
||||
const nonEmptyImages = computed(() => {
|
||||
return props.images.filter(url => url);
|
||||
});
|
||||
|
||||
// Show different number of images based on reactive window width
|
||||
const displayedImages = computed(() => {
|
||||
const images = nonEmptyImages.value;
|
||||
if (windowWidth.value >= 1024) {
|
||||
return images.slice(0, 5); // 5 images on large screens
|
||||
} else if (windowWidth.value >= 768) {
|
||||
return images.slice(0, 4); // 4 images on medium screens
|
||||
}
|
||||
return images.slice(0, 3); // 3 images on smaller screens
|
||||
});
|
||||
|
||||
// Add computed property for grid columns based on number of images
|
||||
const gridColumns = computed(() => {
|
||||
const count = displayedImages.value.length;
|
||||
if (count === 1) return '1fr';
|
||||
if (count === 2) return 'repeat(2, 1fr)';
|
||||
if (count === 3) return 'repeat(3, 1fr)';
|
||||
if (count === 4) return 'repeat(4, 1fr)';
|
||||
return 'repeat(5, 1fr)';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.album-view {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
@apply w-full grid;
|
||||
grid-template-columns: v-bind(gridColumns);
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
@apply relative w-full aspect-square;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"creator": {
|
||||
"sections": {
|
||||
"album": {
|
||||
"title": "Photo Album",
|
||||
"image": "Album image"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"creator": {
|
||||
"sections": {
|
||||
"album": {
|
||||
"title": "Album photo",
|
||||
"image": "Image de l'album"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,207 +0,0 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:scrim="true"
|
||||
fullscreen
|
||||
transition="dialog-bottom-transition"
|
||||
@click:outside="closeViewer"
|
||||
>
|
||||
<div
|
||||
class="album-viewer"
|
||||
@click.self="closeViewer"
|
||||
>
|
||||
<!-- Main image container -->
|
||||
<div class="image-container">
|
||||
<img
|
||||
:alt="t('viewer.imageAlt', { index: currentIndex + 1 })"
|
||||
:src="currentImage"
|
||||
class="main-image"
|
||||
/>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<button
|
||||
:disabled="currentIndex === 0"
|
||||
:title="t('viewer.previous')"
|
||||
class="nav-btn left-btn"
|
||||
@click.stop="previousImage"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiChevronLeft"
|
||||
color="white"
|
||||
size="large"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:disabled="currentIndex === images.length - 1"
|
||||
:title="t('viewer.next')"
|
||||
class="nav-btn right-btn"
|
||||
@click.stop="nextImage"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiChevronRight"
|
||||
color="white"
|
||||
size="large"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
:title="t('viewer.close')"
|
||||
class="close-btn"
|
||||
@click.stop="closeViewer"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiClose"
|
||||
color="white"
|
||||
size="large"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Image counter -->
|
||||
<div class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
startIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const dialog = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const currentImage = computed(() => props.images[currentIndex.value]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newVal => {
|
||||
dialog.value = newVal;
|
||||
if (newVal) {
|
||||
currentIndex.value = props.startIndex;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => dialog.value,
|
||||
newVal => {
|
||||
emit('update:modelValue', newVal);
|
||||
}
|
||||
);
|
||||
|
||||
function nextImage() {
|
||||
if (currentIndex.value < props.images.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.album-viewer {
|
||||
@apply fixed inset-0;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-black bg-opacity-90;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
@apply relative;
|
||||
@apply max-w-[90vw];
|
||||
@apply max-h-[90vh];
|
||||
}
|
||||
|
||||
.main-image {
|
||||
@apply max-w-full;
|
||||
@apply max-h-[90vh];
|
||||
@apply object-contain;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@apply absolute top-1/2 -translate-y-1/2;
|
||||
@apply p-4;
|
||||
@apply rounded-full;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-opacity-75;
|
||||
@apply disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
@apply left-4;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
@apply right-4;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@apply absolute top-4 right-4;
|
||||
@apply p-2;
|
||||
@apply rounded-full;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-opacity-75;
|
||||
}
|
||||
|
||||
.image-counter {
|
||||
@apply absolute bottom-4 left-1/2 -translate-x-1/2;
|
||||
@apply px-4 py-2;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply text-white;
|
||||
@apply rounded-full;
|
||||
@apply text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"viewer": {
|
||||
"previous": "Previous image",
|
||||
"next": "Next image",
|
||||
"close": "Close viewer",
|
||||
"imageAlt": "Image {index}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"viewer": {
|
||||
"previous": "Image précédente",
|
||||
"next": "Image suivante",
|
||||
"close": "Fermer",
|
||||
"imageAlt": "Image {index}"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,170 +0,0 @@
|
||||
<script setup>
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import DonationButton from '@/views/creators/DonationButton.vue';
|
||||
import CreatorLogo from '@/views/creators/CreatorLogo.vue';
|
||||
import NameTitle from '@/views/creators/NameTitle.vue';
|
||||
import Linkedin from '@/views/svg/Linkedin.vue';
|
||||
import X from '@/views/svg/X.vue';
|
||||
import Facebook from '@/views/svg/Facebook.vue';
|
||||
import Instagram from '@/views/svg/Instagram.vue';
|
||||
import Tiktok from '@/views/svg/Tiktok.vue';
|
||||
import Reddit from '@/views/svg/Reddit.vue';
|
||||
import Youtube from '@/views/svg/Youtube.vue';
|
||||
import Web from '@/views/svg/Web.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const brandingStore = useBrandingStore();
|
||||
const baseURL = window.location.origin;
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-column w-full">
|
||||
<!-- Container principal avec le profil -->
|
||||
<div class="relative w-full">
|
||||
<div class="bg-hPrimary text-hOnPrimary relative">
|
||||
<!-- Portrait that overlaps both sections -->
|
||||
<div class="absolute left-4 -bottom-2 z-10">
|
||||
<creator-logo />
|
||||
</div>
|
||||
|
||||
<!-- Desktop version (visible only on écrans moyens et grands) -->
|
||||
<div class="social-info">
|
||||
<div class="w-36"></div>
|
||||
<div class="flex-grow flex flex-row">
|
||||
<div class="flex-grow">
|
||||
<name-title></name-title>
|
||||
</div>
|
||||
<div class="hidden sm:flex pr-6">
|
||||
<DonationButton
|
||||
v-if="brandingStore.value?.acceptDonation"
|
||||
:creator-id="brandingStore.value?.id"
|
||||
:creator-name="brandingStore.value?.name"
|
||||
:on-cancelled-url="baseURL + '/@' + brandingStore.value.slug + '/tip-cancelled'"
|
||||
:on-success-url="baseURL + '/@' + brandingStore.value.slug + '/tip-completed'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section pour les icônes de réseaux sociaux -->
|
||||
<div class="h-12 flex w-full items-center justify-center bg-hSecondary text-hOnSecondary relative">
|
||||
<div class="flex flex-row gap-10">
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.facebookUrl"
|
||||
:href="brandingStore.value?.socials?.facebookUrl"
|
||||
:title="t('facebook')"
|
||||
target="_blank"
|
||||
>
|
||||
<facebook class="social-icon"></facebook>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.instagramUrl"
|
||||
:href="brandingStore.value?.socials?.instagramUrl"
|
||||
:title="t('instagram')"
|
||||
target="_blank"
|
||||
>
|
||||
<instagram class="social-icon"></instagram>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.linkedInUrl"
|
||||
:href="brandingStore.value?.socials?.linkedInUrl"
|
||||
:title="t('linkedin')"
|
||||
target="_blank"
|
||||
>
|
||||
<linkedin class="social-icon"></linkedin>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.redditUrl"
|
||||
:href="brandingStore.value?.socials?.redditUrl"
|
||||
:title="t('reddit')"
|
||||
target="_blank"
|
||||
>
|
||||
<reddit class="social-icon"></reddit>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.tikTokUrl"
|
||||
:href="brandingStore.value?.socials?.tikTokUrl"
|
||||
:title="t('tiktok')"
|
||||
target="_blank"
|
||||
>
|
||||
<tiktok class="social-icon"></tiktok>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.xUrl"
|
||||
:href="brandingStore.value?.socials?.xUrl"
|
||||
:title="t('x')"
|
||||
target="_blank"
|
||||
>
|
||||
<x class="social-icon"></x>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.youtubeUrl"
|
||||
:href="brandingStore.value?.socials?.youtubeUrl"
|
||||
:title="t('youtube')"
|
||||
target="_blank"
|
||||
>
|
||||
<youtube class="social-icon"></youtube>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="brandingStore.value?.socials?.websiteUrl"
|
||||
:href="brandingStore.value?.socials?.websiteUrl"
|
||||
:title="t('website')"
|
||||
target="_blank"
|
||||
>
|
||||
<web class="social-icon"></web>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.social-icon {
|
||||
@apply w-5 h-5;
|
||||
@apply text-base;
|
||||
@apply transform transition-transform duration-200;
|
||||
@apply hover:scale-125 hover:text-fuchsia-900;
|
||||
}
|
||||
|
||||
.social-info {
|
||||
@apply flex flex-row;
|
||||
@apply py-4 w-full;
|
||||
@apply justify-center;
|
||||
@apply max-h-52;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"x": "X (Twitter)",
|
||||
"youtube": "YouTube",
|
||||
"website": "Website"
|
||||
},
|
||||
"fr": {
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"x": "X (Twitter)",
|
||||
"youtube": "YouTube",
|
||||
"website": "Site web"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,377 +0,0 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="card-text">
|
||||
{{ t('description') }}
|
||||
</p>
|
||||
|
||||
<div class="file-input-container">
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<button
|
||||
class="choose-file-button"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
{{ t('chooseImage') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="error-message"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showCropper"
|
||||
class="cropper-wrapper"
|
||||
>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:aspect-ratio="4"
|
||||
:src="fileUrl"
|
||||
:stencil-props="{
|
||||
aspectRatio: 4,
|
||||
class: 'banner-stencil',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="image-preview-container"
|
||||
@click="startEditing"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<img
|
||||
:alt="t('preview')"
|
||||
:src="fileUrl || fallbackUrl"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div class="edit-overlay">
|
||||
<span class="edit-text">{{ t('clickToEdit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
:disabled="isUploading"
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!selectedFile || isUploading"
|
||||
class="primary"
|
||||
@click="showCropper ? applyCrop() : publish()"
|
||||
>
|
||||
<template v-if="isUploading">
|
||||
<span class="loading-spinner"></span>
|
||||
{{ t('uploading') }} ({{ uploadProgress }}%)
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ showCropper ? t('apply') : t('save') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { Cropper } from 'vue-advanced-cropper';
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const selectedFile = ref(null);
|
||||
const fileUrl = ref(props.creator?.bannerUrl);
|
||||
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png';
|
||||
const errorMessage = ref('');
|
||||
const showCropper = ref(false);
|
||||
const cropper = ref(null);
|
||||
const isUploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
|
||||
// Get translations for this component
|
||||
const { t } = useI18n();
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileSelected = event => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
selectedFile.value = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
fileUrl.value = e.target.result;
|
||||
showCropper.value = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
selectedFile.value = null;
|
||||
fileUrl.value = null;
|
||||
showCropper.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
|
||||
// Only try to load the image if it's a data URL (newly selected image)
|
||||
const blob = dataURLtoBlob(fileUrl.value);
|
||||
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
|
||||
showCropper.value = true;
|
||||
} else {
|
||||
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
|
||||
triggerFileInput();
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to convert data URL to blob
|
||||
const dataURLtoBlob = dataURL => {
|
||||
const arr = dataURL.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
};
|
||||
|
||||
const applyCrop = () => {
|
||||
if (!cropper.value) return;
|
||||
|
||||
const canvas = cropper.value.getResult().canvas;
|
||||
canvas.toBlob(blob => {
|
||||
const croppedFile = new File([blob], selectedFile.value.name, {
|
||||
type: selectedFile.value.type,
|
||||
});
|
||||
selectedFile.value = croppedFile;
|
||||
fileUrl.value = canvas.toDataURL();
|
||||
showCropper.value = false;
|
||||
}, selectedFile.value.type);
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const publish = async () => {
|
||||
if (!selectedFile.value || isUploading.value) return;
|
||||
|
||||
try {
|
||||
isUploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile.value);
|
||||
|
||||
const response = await client.post(`/api/creators/${props.creator.id}/banner`, formData, {
|
||||
onUploadProgress: progressEvent => {
|
||||
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
},
|
||||
});
|
||||
|
||||
props.creator.bannerUrl = `${response.data.blobUrl}?t=${Date.now()}`;
|
||||
fileUrl.value = props.creator.bannerUrl;
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
errorMessage.value = t('errors.imageUpload');
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
showCropper.value = false;
|
||||
// Reset to original state if we were editing
|
||||
if (props.creator?.bannerUrl) {
|
||||
fileUrl.value = props.creator.bannerUrl;
|
||||
selectedFile.value = null;
|
||||
} else {
|
||||
fileUrl.value = fallbackUrl;
|
||||
selectedFile.value = null;
|
||||
}
|
||||
emits('closeRequested');
|
||||
};
|
||||
|
||||
// Add drop handler
|
||||
const handleDrop = event => {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
selectedFile.value = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
fileUrl.value = e.target.result;
|
||||
showCropper.value = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-text {
|
||||
@apply font-sans text-lg;
|
||||
}
|
||||
|
||||
.image-preview-container {
|
||||
@apply mb-5;
|
||||
@apply w-full;
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply overflow-hidden;
|
||||
@apply rounded-lg;
|
||||
@apply relative;
|
||||
@apply cursor-pointer;
|
||||
@apply border-2;
|
||||
@apply border-dashed;
|
||||
@apply border-gray-300;
|
||||
@apply hover:border-gray-500;
|
||||
@apply transition-colors;
|
||||
@apply duration-200;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
@apply w-full;
|
||||
@apply aspect-[4/1];
|
||||
@apply object-cover;
|
||||
}
|
||||
|
||||
.edit-overlay {
|
||||
@apply absolute;
|
||||
@apply inset-0;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply bg-black;
|
||||
@apply bg-opacity-0;
|
||||
@apply transition-opacity;
|
||||
@apply duration-200;
|
||||
}
|
||||
|
||||
.image-preview-container:hover .edit-overlay {
|
||||
@apply bg-opacity-30;
|
||||
}
|
||||
|
||||
.edit-text {
|
||||
@apply text-white;
|
||||
@apply font-medium;
|
||||
@apply opacity-0;
|
||||
@apply transition-opacity;
|
||||
@apply duration-200;
|
||||
}
|
||||
|
||||
.image-preview-container:hover .edit-text {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
@apply mb-5;
|
||||
@apply w-full;
|
||||
@apply h-[400px];
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.file-input-container {
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.choose-file-button {
|
||||
@apply px-4;
|
||||
@apply py-2;
|
||||
@apply primary;
|
||||
@apply rounded-lg;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-red-500;
|
||||
@apply mt-2;
|
||||
@apply text-center;
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
:deep(.banner-stencil) {
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
}
|
||||
|
||||
:deep(.cropper) {
|
||||
@apply max-h-full;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply inline-block;
|
||||
@apply w-4;
|
||||
@apply h-4;
|
||||
@apply mr-2;
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
@apply border-t-transparent;
|
||||
@apply rounded-full;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Banner Editor",
|
||||
"description": "Upload or edit your profile banner image. The recommended size is 1024x256 pixels (4:1 ratio).",
|
||||
"chooseImage": "Choose an image",
|
||||
"clickToEdit": "Click to edit",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Éditeur de bannière",
|
||||
"description": "Téléchargez ou modifiez votre image de bannière de profil. La taille recommandée est de 1024x256 pixels (ratio 4:1).",
|
||||
"chooseImage": "Choisir une image",
|
||||
"clickToEdit": "Cliquez pour modifier",
|
||||
"uploading": "Téléchargement"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import NameEditor from '@/views/creators/NameEditor.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const creatorName = ref('');
|
||||
const creatorNameReservationId = ref(undefined);
|
||||
const canSave = computed(() => creatorNameReservationId.value !== undefined);
|
||||
|
||||
const isOperationPending = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
function handleCreatorNameReservationIdChanged($event) {
|
||||
creatorNameReservationId.value = $event;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
// if a returnUrl query‑string was supplied, prefer it
|
||||
const returnUrl = route.query.returnUrl;
|
||||
if (typeof returnUrl === 'string' && returnUrl.length) {
|
||||
router.push(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise just go back one step in history
|
||||
router.back();
|
||||
}
|
||||
|
||||
// TODO: The `fetchCreatorProfile` function should be private (push-up to the store)!
|
||||
async function createAccount() {
|
||||
try {
|
||||
isOperationPending.value = true;
|
||||
const client = useClient();
|
||||
errorMessage.value = '';
|
||||
await client.post('/api/creators', {
|
||||
creatorId: userProfileStore.user.id,
|
||||
slugReservationId: creatorNameReservationId.value,
|
||||
});
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
await router.push(`/@${creatorProfileStore.creator.slug}`);
|
||||
} catch (error) {
|
||||
if (error?.response?.data?.errors) {
|
||||
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
|
||||
} else {
|
||||
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
|
||||
}
|
||||
} finally {
|
||||
isOperationPending.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<name-editor
|
||||
v-model:name="creatorName"
|
||||
:creator-name-reservation-id="creatorNameReservationId"
|
||||
@update:creator-name-reservation-id="handleCreatorNameReservationIdChanged"
|
||||
></name-editor>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canSave || isOperationPending"
|
||||
class="primary"
|
||||
@click="createAccount"
|
||||
>
|
||||
{{ t('create') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="!!errorMessage"
|
||||
outlined
|
||||
type="error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
@apply min-h-screen w-full;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Create your Hutopy",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create my page",
|
||||
"errors": {
|
||||
"unexpected": "An unexpected error occurred"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"title": "Créez votre Hutopy",
|
||||
"cancel": "Annuler",
|
||||
"create": "Créer ma page",
|
||||
"errors": {
|
||||
"unexpected": "Une erreur inattendue s'est produite"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div class="creator-home">
|
||||
<!-- Content sections container -->
|
||||
<div class="content-sections">
|
||||
<!-- Donation Section -->
|
||||
<div
|
||||
v-if="brandingStore.value?.acceptDonation"
|
||||
class="section sm:hidden"
|
||||
>
|
||||
<DonationButton
|
||||
:creator-id="brandingStore.value?.id"
|
||||
:creator-name="brandingStore.value?.name"
|
||||
:on-cancelled-url="baseURL + '/paymentfailed/' + brandingStore.value?.id"
|
||||
:on-success-url="baseURL + '/paymentcompleted/' + brandingStore.value?.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- About Creator Section -->
|
||||
<div class="section">
|
||||
<AboutCreator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AboutCreator from './AboutCreator.vue';
|
||||
import DonationButton from '@/views/creators/DonationButton.vue';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
|
||||
const brandingStore = useBrandingStore();
|
||||
const baseURL = window.location.origin;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creator-home {
|
||||
@apply w-full;
|
||||
@apply p-5;
|
||||
}
|
||||
|
||||
.content-sections {
|
||||
@apply flex flex-col;
|
||||
@apply gap-5;
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply rounded-2xl;
|
||||
@apply shadow-2xl;
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.section::before {
|
||||
content: '';
|
||||
@apply absolute inset-0;
|
||||
@apply rounded-2xl;
|
||||
@apply p-[1px];
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(64, 64, 64, 1) 0%,
|
||||
rgba(64, 64, 64, 0) 20%,
|
||||
rgba(64, 64, 64, 0.5) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,146 +0,0 @@
|
||||
<script async setup>
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import { onMounted } from 'vue';
|
||||
import Footer from '@/views/main/Footer.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ActualBanner from '@/views/creators/ActualBanner.vue';
|
||||
import BannerActions from '@/views/creators/BannerActions.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const brandingStore = useBrandingStore();
|
||||
const creatorName = window.location.pathname.split('/@')[1]?.split('/')[0] || '';
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
await brandingStore.updateBrand(creatorName);
|
||||
});
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/landing');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen max-w-[1024px] bg-hSurface text-hOnSurface">
|
||||
<div v-if="brandingStore.loading">
|
||||
<v-progress-linear indeterminate></v-progress-linear>
|
||||
</div>
|
||||
|
||||
<!-- 404 Error State -->
|
||||
<div
|
||||
v-else-if="brandingStore.notFound"
|
||||
class="flex flex-col items-center justify-center min-h-screen p-8"
|
||||
>
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-6xl font-bold text-gray-400 mb-4">404</div>
|
||||
<h1 class="text-2xl font-semibold mb-4">{{ t('creator.notFound.title') }}</h1>
|
||||
<p class="text-gray-600 mb-8">{{ t('creator.notFound.message', { creator: creatorName }) }}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<v-btn
|
||||
class="w-full"
|
||||
color="primary"
|
||||
size="large"
|
||||
@click="goHome"
|
||||
>
|
||||
{{ t('creator.notFound.goHome') }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
class="w-full"
|
||||
size="large"
|
||||
variant="outlined"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ t('creator.notFound.goBack') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State (non-404 errors) -->
|
||||
<div
|
||||
v-else-if="brandingStore.error && !brandingStore.notFound"
|
||||
class="flex flex-col items-center justify-center min-h-screen p-8"
|
||||
>
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-4xl font-bold text-red-400 mb-4">⚠️</div>
|
||||
<h1 class="text-2xl font-semibold mb-4">{{ t('creator.error.title') }}</h1>
|
||||
<p class="text-gray-600 mb-8">{{ t('creator.error.message') }}</p>
|
||||
|
||||
<v-btn
|
||||
class="w-full"
|
||||
color="primary"
|
||||
size="large"
|
||||
@click="goHome"
|
||||
>
|
||||
{{ t('creator.error.goHome') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="brandingStore.value.isDeleted"
|
||||
class="bg-red-500 p-2 m-4 text-center font-semibold"
|
||||
>
|
||||
{{ t('creator.layout.deletion.pending') }}
|
||||
</div>
|
||||
<actual-banner></actual-banner>
|
||||
<banner-actions></banner-actions>
|
||||
<router-view></router-view>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"creator": {
|
||||
"layout": {
|
||||
"deletion": {
|
||||
"pending": "This creator page is pending deletion"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Creator Not Found",
|
||||
"message": "The creator '{creator}' doesn't exist or may have been removed.",
|
||||
"goHome": "Go to Home",
|
||||
"goBack": "Go Back"
|
||||
},
|
||||
"error": {
|
||||
"title": "Something Went Wrong",
|
||||
"message": "We're having trouble loading this creator page. Please try again later.",
|
||||
"goHome": "Go to Home"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"creator": {
|
||||
"layout": {
|
||||
"deletion": {
|
||||
"pending": "Cette page créateur est en attente de suppression"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Créateur Introuvable",
|
||||
"message": "Le créateur '{creator}' n'existe pas ou a peut-être été supprimé.",
|
||||
"goHome": "Aller à l'accueil",
|
||||
"goBack": "Retour"
|
||||
},
|
||||
"error": {
|
||||
"title": "Quelque chose s'est mal passé",
|
||||
"message": "Nous avons des difficultés à charger cette page créateur. Veuillez réessayer plus tard.",
|
||||
"goHome": "Aller à l'accueil"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@click="isCurrentCreator && openBannerEditor()"
|
||||
@mouseenter="showTint = isCurrentCreator"
|
||||
@mouseleave="showTint = false"
|
||||
>
|
||||
<div class="size-[110px] rounded-full border-4 border-hPrimary">
|
||||
<img
|
||||
:alt="t('logoAlt')"
|
||||
:src="brandingStore.value?.portraitUrl ?? '/images/placeholders/profile.png'"
|
||||
class="rounded-full"
|
||||
height="110px"
|
||||
width="110px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tint Effect -->
|
||||
<div
|
||||
v-if="showTint"
|
||||
:title="t('editLogo')"
|
||||
class="absolute inset-0 cursor-pointer rounded-full bg-black/25"
|
||||
>
|
||||
<!-- Top-right Icon -->
|
||||
<div
|
||||
class="absolute right-0 top-0 flex size-12 items-center justify-center rounded-full bg-hutopyPrimary shadow-lg"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiPencil"
|
||||
large
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-dialog
|
||||
v-model="isDialogOpen"
|
||||
max-width="800px"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<creator-logo-editor
|
||||
:creator="brandingStore?.value"
|
||||
@closeRequested="() => (isDialogOpen = false)"
|
||||
></creator-logo-editor>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import CreatorLogoEditor from '@/views/creators/CreatorLogoEditor.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiPencil } from '@mdi/js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const brandingStore = useBrandingStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const showTint = ref(false);
|
||||
const isDialogOpen = ref(false);
|
||||
|
||||
// Methods
|
||||
const openBannerEditor = () => {
|
||||
isDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const isCurrentCreator = computed(() => {
|
||||
return authStore.userId === brandingStore.value.id;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo-image {
|
||||
@apply border-4 border-solid border-hTertiary;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"logoAlt": "Creator logo",
|
||||
"editLogo": "Edit logo"
|
||||
},
|
||||
"fr": {
|
||||
"logoAlt": "Logo du créateur",
|
||||
"editLogo": "Modifier le logo"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,398 +0,0 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('logoTitle') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="card-text">
|
||||
{{ t('logoDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="file-input-container">
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<button
|
||||
class="choose-file-button"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
{{ t('chooseImage') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="error-message"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showCropper"
|
||||
class="cropper-wrapper"
|
||||
>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:aspect-ratio="1"
|
||||
:src="fileUrl"
|
||||
:stencil-component="CircleStencil"
|
||||
:stencil-props="{
|
||||
aspectRatio: 1,
|
||||
class: 'circle-stencil',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="image-preview-container"
|
||||
@click="startEditing"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="circular-preview">
|
||||
<img
|
||||
:alt="t('preview')"
|
||||
:src="fileUrl || fallbackUrl"
|
||||
class="preview-image"
|
||||
/>
|
||||
<div class="edit-overlay">
|
||||
<span class="edit-text">{{ t('clickToEdit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
:disabled="isUploading"
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!selectedFile || isUploading"
|
||||
class="primary"
|
||||
@click="showCropper ? applyCrop() : publish()"
|
||||
>
|
||||
<template v-if="isUploading">
|
||||
<span class="loading-spinner"></span>
|
||||
{{ t('uploading') }} ({{ uploadProgress }}%)
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ showCropper ? t('apply') : t('save') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { CircleStencil, Cropper } from 'vue-advanced-cropper';
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const selectedFile = ref(null);
|
||||
const fileUrl = ref(props.creator.portraitUrl);
|
||||
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png';
|
||||
const errorMessage = ref('');
|
||||
const showCropper = ref(false);
|
||||
const cropper = ref(null);
|
||||
const isUploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
|
||||
const TARGET_WIDTH = 200;
|
||||
const TARGET_HEIGHT = 200;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''; // Reset the input value to ensure the change event fires
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileSelected = event => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
selectedFile.value = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
fileUrl.value = e.target.result;
|
||||
showCropper.value = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
selectedFile.value = null;
|
||||
fileUrl.value = null;
|
||||
showCropper.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
if (fileUrl.value && fileUrl.value.startsWith('data:')) {
|
||||
// Only try to load the image if it's a data URL (newly selected image)
|
||||
const blob = dataURLtoBlob(fileUrl.value);
|
||||
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' });
|
||||
showCropper.value = true;
|
||||
} else {
|
||||
// If no image is selected, using fallback, or have an existing uploaded image, trigger the file input
|
||||
triggerFileInput();
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to convert data URL to blob
|
||||
const dataURLtoBlob = dataURL => {
|
||||
const arr = dataURL.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
};
|
||||
|
||||
const applyCrop = () => {
|
||||
if (!cropper.value) return;
|
||||
|
||||
const canvas = cropper.value.getResult().canvas;
|
||||
canvas.toBlob(blob => {
|
||||
const croppedFile = new File([blob], selectedFile.value.name, {
|
||||
type: selectedFile.value.type,
|
||||
});
|
||||
selectedFile.value = croppedFile;
|
||||
fileUrl.value = canvas.toDataURL();
|
||||
showCropper.value = false;
|
||||
}, selectedFile.value.type);
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const publish = async () => {
|
||||
if (!selectedFile.value || isUploading.value) return;
|
||||
|
||||
try {
|
||||
isUploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile.value);
|
||||
|
||||
const response = await client.post(`/api/creators/${props.creator.id}/logo`, formData, {
|
||||
onUploadProgress: progressEvent => {
|
||||
uploadProgress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
},
|
||||
});
|
||||
|
||||
props.creator.portraitUrl = `${response.data.blobUrl}?t=${Date.now()}`;
|
||||
if (props.creator.portraitUrl) {
|
||||
fileUrl.value = props.creator.portraitUrl;
|
||||
}
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
errorMessage.value = t('errors.imageUpload');
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
showCropper.value = false;
|
||||
// Reset to original state if we were editing
|
||||
if (props.creator.portraitUrl) {
|
||||
fileUrl.value = props.creator.portraitUrl;
|
||||
selectedFile.value = null;
|
||||
} else {
|
||||
fileUrl.value = fallbackUrl;
|
||||
selectedFile.value = null;
|
||||
}
|
||||
emits('closeRequested');
|
||||
};
|
||||
|
||||
// Add drop handler
|
||||
const handleDrop = event => {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
selectedFile.value = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
fileUrl.value = e.target.result;
|
||||
showCropper.value = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-text {
|
||||
@apply font-sans text-lg;
|
||||
}
|
||||
|
||||
.image-preview-container {
|
||||
@apply mb-5;
|
||||
@apply w-full;
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply border-2;
|
||||
@apply border-dashed;
|
||||
@apply border-gray-300;
|
||||
@apply hover:border-gray-500;
|
||||
@apply transition-colors;
|
||||
@apply duration-200;
|
||||
@apply rounded-lg;
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.circular-preview {
|
||||
@apply w-[200px];
|
||||
@apply h-[200px];
|
||||
@apply rounded-full;
|
||||
@apply overflow-hidden;
|
||||
@apply border-2;
|
||||
@apply border-gray-200;
|
||||
@apply relative;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
@apply w-full;
|
||||
@apply h-full;
|
||||
@apply object-cover;
|
||||
}
|
||||
|
||||
.edit-overlay {
|
||||
@apply absolute;
|
||||
@apply inset-0;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply bg-black;
|
||||
@apply bg-opacity-0;
|
||||
@apply transition-opacity;
|
||||
@apply duration-200;
|
||||
}
|
||||
|
||||
.circular-preview:hover .edit-overlay {
|
||||
@apply bg-opacity-30;
|
||||
}
|
||||
|
||||
.edit-text {
|
||||
@apply text-white;
|
||||
@apply font-medium;
|
||||
@apply opacity-0;
|
||||
@apply transition-opacity;
|
||||
@apply duration-200;
|
||||
}
|
||||
|
||||
.circular-preview:hover .edit-text {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
@apply mb-5;
|
||||
@apply w-full;
|
||||
@apply h-[400px];
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.file-input-container {
|
||||
@apply flex;
|
||||
@apply justify-center;
|
||||
@apply items-center;
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.choose-file-button {
|
||||
@apply px-4;
|
||||
@apply py-2;
|
||||
@apply primary;
|
||||
@apply rounded-lg;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-red-500;
|
||||
@apply mt-2;
|
||||
@apply text-center;
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
:deep(.circle-stencil) {
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
:deep(.cropper) {
|
||||
@apply max-h-full;
|
||||
}
|
||||
|
||||
:deep(.cropper__stencil) {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply inline-block;
|
||||
@apply w-4;
|
||||
@apply h-4;
|
||||
@apply mr-2;
|
||||
@apply border-2;
|
||||
@apply border-white;
|
||||
@apply border-t-transparent;
|
||||
@apply rounded-full;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"logoTitle": "Edit Logo",
|
||||
"logoDescription": "Choose a logo image for your creator page. The image will be cropped to a circle.",
|
||||
"chooseImage": "Choose Image",
|
||||
"clickToEdit": "Click to edit",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"fr": {
|
||||
"logoTitle": "Modifier le logo",
|
||||
"logoDescription": "Choisissez une image de logo pour votre page de créateur. L'image sera recadrée en cercle.",
|
||||
"chooseImage": "Choisir une image",
|
||||
"clickToEdit": "Cliquez pour modifier",
|
||||
"uploading": "Téléchargement"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="secondary donation-action"
|
||||
@click="openDonationDialog()"
|
||||
>
|
||||
{{ t('creator.donation.isupport') }}
|
||||
</button>
|
||||
|
||||
<DonationDialog
|
||||
ref="donationDialogRef"
|
||||
:creator-id="creatorId"
|
||||
:creator-name="creatorName"
|
||||
:icon-color-class="iconColorClass"
|
||||
:on-cancelled-url="onCancelledUrl"
|
||||
:on-success-url="onSuccessUrl"
|
||||
@close="handleDialogClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DonationDialog from './DonationDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
creatorId: { default: 'missing-creator-id', required: true },
|
||||
creatorName: { default: 'missing-creator-name', required: true },
|
||||
onSuccessUrl: { default: 'missing-on-success-u', required: true },
|
||||
onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
|
||||
iconColorClass: { default: 'text-black' },
|
||||
});
|
||||
|
||||
const donationDialogRef = ref(null);
|
||||
|
||||
function openDonationDialog() {
|
||||
donationDialogRef.value.openDonationDialog();
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
// Handle any cleanup or additional logic when dialog closes
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.donation-action {
|
||||
@apply bg-hutopyPrimary text-hOnPrimary;
|
||||
@apply hover:bg-hutopySecondary;
|
||||
@apply w-fit place-self-center;
|
||||
@apply h-12;
|
||||
@apply rounded-2xl w-full;
|
||||
@apply font-sans font-semibold text-lg;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"creator": {
|
||||
"donation": {
|
||||
"isupport": "I Support"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"creator": {
|
||||
"donation": {
|
||||
"isupport": "Je Soutiens"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<v-dialog v-model="donationModal">
|
||||
<DonationForm
|
||||
:show-cancel-button="showCancelButton"
|
||||
:creator-id="creatorId"
|
||||
:creator-name="creatorName"
|
||||
:on-success-url="onSuccessUrl"
|
||||
:on-cancelled-url="onCancelledUrl"
|
||||
@cancel="closeDonationDialog"
|
||||
/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DonationForm from './DonationForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
creatorId: { default: 'missing-creator-id', required: true },
|
||||
creatorName: { default: 'missing-creator-name', required: true },
|
||||
onSuccessUrl: { default: 'missing-on-success-u', required: true },
|
||||
onCancelledUrl: { default: 'missing-on-cancelled-url', required: true },
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const donationModal = ref(false);
|
||||
|
||||
function openDonationDialog() {
|
||||
donationModal.value = true;
|
||||
}
|
||||
|
||||
function closeDonationDialog() {
|
||||
donationModal.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDonationDialog,
|
||||
});
|
||||
</script>
|
||||
@@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog bg-hSurface text-hOnSurface">
|
||||
<div class="card-title">
|
||||
{{ t('creator.donation.isupport') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="tipAmountInDollars"
|
||||
:label="t('creator.donation.amount')"
|
||||
:min="0"
|
||||
autofocus
|
||||
class="p-2"
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
prepend-inner-icon="mdi-currency-usd"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@keydown="preventNonNumeric"
|
||||
></v-text-field>
|
||||
|
||||
<v-textarea
|
||||
v-model="tipMessage"
|
||||
:label="t('creator.donation.message')"
|
||||
auto-grow
|
||||
class="p-2"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details
|
||||
rows="2"
|
||||
variant="outlined"
|
||||
></v-textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
v-if="showCancelButton"
|
||||
class="secondary"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
:disabled="isProcessing"
|
||||
class="primary"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span
|
||||
v-if="isProcessing"
|
||||
class="spinner mr-2"
|
||||
></span>
|
||||
{{ isProcessing ? t('creator.donation.processing') : t('creator.donation.send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useToast } from 'vue-toastification';
|
||||
|
||||
const { t } = useI18n();
|
||||
const client = useClient();
|
||||
const toast = useToast();
|
||||
|
||||
const props = defineProps({
|
||||
showCancelButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
creatorId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
onSuccessUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
onCancelledUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cancel', 'submit']);
|
||||
|
||||
const tipAmountInDollars = ref('');
|
||||
const tipMessage = ref('');
|
||||
const isProcessing = ref(false);
|
||||
|
||||
function preventNonNumeric(event) {
|
||||
const key = event.key;
|
||||
const allowedKeys = ['Backspace', 'ArrowLeft', 'ArrowRight', 'Delete'];
|
||||
|
||||
if (!/^\d$/.test(key) && !allowedKeys.includes(key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!tipAmountInDollars.value || tipAmountInDollars.value <= 0) {
|
||||
toast.warning(t('creator.donation.errors.invalidAmount'));
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/tips`, {
|
||||
creatorId: props.creatorId,
|
||||
amount: tipAmountInDollars.value * 100,
|
||||
currency: 'CAD',
|
||||
message: tipMessage.value,
|
||||
checkoutSuccessUrl: props.onSuccessUrl,
|
||||
checkoutCancelledUrl: props.onCancelledUrl,
|
||||
});
|
||||
|
||||
if (response.data?.url) {
|
||||
window.location.href = response.data.url;
|
||||
} else {
|
||||
throw new Error('No checkout URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t('creator.donation.errors.payment'));
|
||||
isProcessing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spinner {
|
||||
@apply inline-block;
|
||||
@apply w-4 h-4;
|
||||
@apply border-2;
|
||||
@apply border-current;
|
||||
@apply border-t-transparent;
|
||||
@apply rounded-full;
|
||||
@apply animate-spin;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"common": {
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"creator": {
|
||||
"donation": {
|
||||
"isupport": "I Support",
|
||||
"amount": "Amount ($)",
|
||||
"message": "Message (optional)",
|
||||
"send": "Send",
|
||||
"processing": "Processing...",
|
||||
"errors": {
|
||||
"payment": "An error occurred during payment processing",
|
||||
"invalidAmount": "Please enter a valid amount"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"common": {
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"creator": {
|
||||
"donation": {
|
||||
"isupport": "Je Soutiens",
|
||||
"amount": "Montant ($)",
|
||||
"message": "Message (optionnel)",
|
||||
"send": "Envoyer",
|
||||
"processing": "Traitement en cours...",
|
||||
"errors": {
|
||||
"payment": "Une erreur s'est produite lors du traitement du paiement",
|
||||
"invalidAmount": "Veuillez entrer un montant valide"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,223 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { v7 } from 'uuid';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import config from '@/config';
|
||||
import { mdiCheckCircle, mdiCloseCircle } from '@mdi/js';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
required: true,
|
||||
},
|
||||
creatorNameReservationId: {
|
||||
required: true,
|
||||
},
|
||||
originalSlug: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:name', 'update:creatorNameReservationId']);
|
||||
|
||||
const name = ref(props.name);
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOperationPending = ref(false);
|
||||
const reservationState = ref(null);
|
||||
const validationError = ref('');
|
||||
|
||||
// Use the reservationId from props if provided, otherwise generate a new one
|
||||
const reservationId = ref(props.creatorNameReservationId || v7());
|
||||
|
||||
// Check if the current name is the same as the original slug
|
||||
const isCurrentSlug = computed(() => {
|
||||
return props.originalSlug && name.value === props.originalSlug;
|
||||
});
|
||||
|
||||
// Base URL for display
|
||||
const baseUrl = computed(() => `${config.baseUrl}/@`);
|
||||
|
||||
// Validation function for the slug
|
||||
const validateSlug = slug => {
|
||||
if (!slug) {
|
||||
validationError.value = t('creator.name.errors.required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only allow letters, numbers, and hyphens
|
||||
const validSlugRegex = /^[a-zA-Z0-9-]+$/;
|
||||
if (!validSlugRegex.test(slug)) {
|
||||
validationError.value = t('creator.name.errors.invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
validationError.value = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
// Ensure we emit the reservationId on mount if we generated a new one
|
||||
onMounted(() => {
|
||||
if (!props.creatorNameReservationId) {
|
||||
emits('update:creatorNameReservationId', reservationId.value);
|
||||
}
|
||||
|
||||
// If the name is the same as the original slug, set the reservation state to "reserved"
|
||||
if (isCurrentSlug.value) {
|
||||
reservationState.value = 'reserved';
|
||||
}
|
||||
});
|
||||
|
||||
// Request handling
|
||||
let currentController = null;
|
||||
let timeout = null;
|
||||
let lastProcessedName = '';
|
||||
|
||||
const cancelCurrentRequest = () => {
|
||||
if (currentController) {
|
||||
currentController.abort();
|
||||
currentController = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
const currentName = name.value;
|
||||
if (currentName === lastProcessedName) {
|
||||
return; // Skip if we've already processed this exact name
|
||||
}
|
||||
|
||||
// Validate the slug
|
||||
if (!validateSlug(currentName)) {
|
||||
reservationState.value = 'unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
// If the name is the same as the original slug, set reservation state to "reserved"
|
||||
if (props.originalSlug && currentName === props.originalSlug) {
|
||||
reservationState.value = 'reserved';
|
||||
lastProcessedName = currentName;
|
||||
emits('update:name', currentName);
|
||||
return;
|
||||
}
|
||||
|
||||
checkNameAvailability(currentName);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const checkNameAvailability = async nameToCheck => {
|
||||
if (!nameToCheck || nameToCheck.trim() === '') {
|
||||
reservationState.value = null;
|
||||
lastProcessedName = nameToCheck;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any ongoing request
|
||||
cancelCurrentRequest();
|
||||
|
||||
try {
|
||||
isOperationPending.value = true;
|
||||
reservationState.value = 'loading';
|
||||
|
||||
// Create a new request with cancellation token
|
||||
const controller = new AbortController();
|
||||
currentController = controller;
|
||||
|
||||
await client.post(
|
||||
`/api/creators/@${encodeURIComponent(nameToCheck)}/reserve`,
|
||||
{ reservationId: reservationId.value },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
// Only process the response if this is still the current request
|
||||
if (currentController === controller) {
|
||||
reservationState.value = 'reserved';
|
||||
lastProcessedName = nameToCheck;
|
||||
emits('update:name', nameToCheck);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only process the error if this is still the current request and it's not an abort error
|
||||
if (currentController && error.name !== 'AbortError') {
|
||||
reservationState.value = 'unavailable';
|
||||
lastProcessedName = nameToCheck;
|
||||
}
|
||||
} finally {
|
||||
if (currentController) {
|
||||
isOperationPending.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on component unmount
|
||||
onUnmounted(() => {
|
||||
cancelCurrentRequest();
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
:error-messages="validationError"
|
||||
:label="t('creator.name.label')"
|
||||
variant="outlined"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<span class="text-nowrap font-sans text-gray-400">{{ baseUrl }}</span>
|
||||
</template>
|
||||
|
||||
<template #append-inner>
|
||||
<v-progress-circular
|
||||
v-if="reservationState === 'loading'"
|
||||
color="grey"
|
||||
indeterminate
|
||||
size="24"
|
||||
width="3"
|
||||
></v-progress-circular>
|
||||
|
||||
<v-icon
|
||||
v-else-if="reservationState === 'reserved'"
|
||||
:icon="mdiCheckCircle"
|
||||
color="green"
|
||||
/>
|
||||
<v-icon
|
||||
v-else-if="reservationState === 'unavailable'"
|
||||
:icon="mdiCloseCircle"
|
||||
color="red"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"creator": {
|
||||
"name": {
|
||||
"label": "Your creator handle",
|
||||
"errors": {
|
||||
"required": "Creator handle is required",
|
||||
"invalid": "Only letters, numbers, and hyphens are allowed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"creator": {
|
||||
"name": {
|
||||
"label": "Votre identifiant de créateur",
|
||||
"errors": {
|
||||
"required": "L'identifiant est requis",
|
||||
"invalid": "Seules les lettres, chiffres et tirets sont autorisés"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-hOnPrimary">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="capitalize text-3xl">
|
||||
{{ brandingStore.value.name }}
|
||||
</span>
|
||||
<div
|
||||
v-show="brandingStore.value.verified"
|
||||
:title="t('verified')"
|
||||
class="text-blue mt-1"
|
||||
>
|
||||
<icon-account-verified></icon-account-verified>
|
||||
</div>
|
||||
</div>
|
||||
<span class="capitalize text-lg">
|
||||
{{ brandingStore.value.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconAccountVerified from '@/components/icons/IconAccountVerified.vue';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const brandingStore = useBrandingStore();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"verified": "Verified Account"
|
||||
},
|
||||
"fr": {
|
||||
"verified": "Compte vérifié"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,148 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<!-- Navigation Link at the top -->
|
||||
<div class="navigation-link">
|
||||
<button
|
||||
class="link-button"
|
||||
@click="goBack()"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
{{ t('returnToCreator') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 v-html="titleWithCreatorName"></h1>
|
||||
|
||||
<p>
|
||||
<v-icon
|
||||
:icon="mdiCheckCircle"
|
||||
color="success"
|
||||
size="120"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ t('message') }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ t('receipt') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBrandingStore } from '@/stores/brandingStore.js';
|
||||
import { mdiArrowLeft, mdiCheckCircle } from '@mdi/js';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const brandingStore = useBrandingStore();
|
||||
|
||||
const creatorName = computed(() => {
|
||||
return route.params.creator?.split('/')[0] || t('usernameDefault');
|
||||
});
|
||||
|
||||
const titleWithCreatorName = computed(() => {
|
||||
return t('title', { creatorName: creatorName.value });
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
// Navigate back to the creator's page
|
||||
const creatorNameParam = route.params.creator?.split('/')[0] || '';
|
||||
router.push(`/@${creatorNameParam}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "{creatorName} thanks you!",
|
||||
"message": "Your payment has been processed successfully.",
|
||||
"usernameDefault": "The creator",
|
||||
"receipt": "A receipt has been sent to your email.",
|
||||
"returnToCreator": "Return to creator page"
|
||||
},
|
||||
"fr": {
|
||||
"title": "{creatorName} vous remercie !",
|
||||
"message": "Votre paiement a été traité avec succès.",
|
||||
"usernameDefault": "Le créateur",
|
||||
"receipt": "Un reçu a été envoyé à votre email.",
|
||||
"returnToCreator": "Retourner à la page du créateur"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
@apply flex items-center justify-center;
|
||||
@apply p-5;
|
||||
@apply w-full;
|
||||
@apply mx-auto;
|
||||
@apply max-w-[1024px];
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-hSurface text-hOnSurface;
|
||||
@apply p-8;
|
||||
@apply font-sans;
|
||||
@apply rounded-2xl;
|
||||
@apply shadow-2xl;
|
||||
@apply relative;
|
||||
@apply w-full;
|
||||
@apply max-w-2xl;
|
||||
@apply mx-auto;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
@apply absolute inset-0;
|
||||
@apply rounded-2xl;
|
||||
@apply p-[1px];
|
||||
content: '';
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(64, 64, 64, 1) 0%,
|
||||
rgba(64, 64, 64, 0) 20%,
|
||||
rgba(64, 64, 64, 0.5) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
@apply flex items-center text-hutopyPrimary;
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
@apply flex items-center gap-2;
|
||||
@apply text-hutopyPrimary hover:text-hutopySecondary;
|
||||
@apply transition-colors;
|
||||
@apply duration-300;
|
||||
@apply text-xl;
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-6xl;
|
||||
@apply font-medium;
|
||||
@apply mb-8;
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-lg;
|
||||
@apply font-medium;
|
||||
@apply mb-8;
|
||||
@apply text-center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,119 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<!-- Navigation Link at the top -->
|
||||
<div class="navigation-link">
|
||||
<button
|
||||
class="link-button"
|
||||
@click="goBack()"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
{{ t('returnToCreator') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>{{ t('title') }}</h1>
|
||||
<p>{{ t('message') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiArrowLeft } from '@mdi/js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
function goBack() {
|
||||
const creatorName = route.params.creator?.split('/')[0] || '';
|
||||
router.push(`/@${creatorName}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Payment Failed",
|
||||
"message": "We couldn't process your payment.",
|
||||
"retry": "Try Again",
|
||||
"returnToCreator": "Return to creator page"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Échec du paiement",
|
||||
"message": "Nous n'avons pas pu traiter votre paiement.",
|
||||
"retry": "Réessayer",
|
||||
"returnToCreator": "Retourner à la page du créateur"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
@apply flex items-center justify-center;
|
||||
@apply p-5;
|
||||
@apply w-full;
|
||||
@apply mx-auto;
|
||||
@apply max-w-[1024px];
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-hSurface text-hOnSurface;
|
||||
@apply p-8;
|
||||
@apply font-sans;
|
||||
@apply rounded-2xl;
|
||||
@apply shadow-2xl;
|
||||
@apply relative;
|
||||
@apply w-full;
|
||||
@apply max-w-2xl;
|
||||
@apply mx-auto;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
@apply absolute inset-0;
|
||||
@apply rounded-2xl;
|
||||
@apply p-[1px];
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(64, 64, 64, 1) 0%,
|
||||
rgba(64, 64, 64, 0) 20%,
|
||||
rgba(64, 64, 64, 0.5) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
@apply flex items-center;
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
@apply flex items-center gap-2;
|
||||
@apply text-hutopyPrimary hover:text-hutopySecondary;
|
||||
@apply transition-colors;
|
||||
@apply duration-300;
|
||||
@apply text-xl;
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-6xl;
|
||||
@apply font-medium;
|
||||
@apply mb-8;
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-lg;
|
||||
@apply font-medium;
|
||||
@apply mb-8;
|
||||
@apply text-center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
|
||||
<h1>À propos</h1>
|
||||
|
||||
<p>
|
||||
Notre mission chez Hutopy est de développer des outils permettant à chaque utilisateur de se démarquer, autant dans
|
||||
le monde réel que dans le monde numérique, en créant un véritable pont entre les deux. Que vous soyez artiste de
|
||||
rue, organisme à but non lucratif ou toute autre personne ayant besoin d’outils pour être soutenue, Hutopy est là
|
||||
pour vous.
|
||||
</p>
|
||||
|
||||
<h2>Notre Histoire</h2>
|
||||
|
||||
<p>
|
||||
Hutopy a été fondée en 2024, née de l'idée simple mais puissante que chaque créateur qu'il
|
||||
soit grand ou petit, novice ou expérimenté, devrait avoir accès aux outils et au soutien
|
||||
nécessaires pour partager sa passion avec le monde.
|
||||
</p>
|
||||
|
||||
<h2>Notre Mission</h2>
|
||||
|
||||
<p>
|
||||
Notre mission est de démocratiser la création de contenu numérique, en offrant une
|
||||
plateforme accessible, intuitive et puissante qui permet aux créateurs de tout horizon de
|
||||
s'exprimer, d'innover et de connecter avec une audience mondiale. Nous nous engageons à
|
||||
fournir les outils, les ressources et le soutien nécessaires pour que chaque voix puisse
|
||||
être entendue.
|
||||
</p>
|
||||
|
||||
<h2>
|
||||
Notre Vision
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Nous envisageons un monde où la barrière entre les créateurs et leur audience est réduite au
|
||||
minimum, où les idées, l'expertise et les histoires peuvent circuler librement et sans
|
||||
entrave. Hutopy aspire à être au cœur de cet écosystème créatif et professionnel, en étant
|
||||
une source d'inspiration, une plateforme de lancement et un foyer pour tous.
|
||||
</p>
|
||||
|
||||
<h2>
|
||||
Notre Équipe
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Derrière Hutopy, il y a une équipe de penseurs innovants, de créatifs passionnés et de
|
||||
technologues dévoués, tous unis par le désir de soutenir la communauté des créateurs de
|
||||
contenu. Notre équipe est notre plus grande force, chaque membre apportant une expertise
|
||||
unique et une perspective fraîche à notre mission commune.
|
||||
</p>
|
||||
|
||||
<div class="members">
|
||||
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<img class="member-profile-picture"
|
||||
src="/images/hutopymedia/tospage/membersPictures/profilePascal.png"
|
||||
alt="Pascal Marchesseault">
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="member-name">Pascal Marchesseault</div>
|
||||
<div class="member-title">Président-directeur général</div>
|
||||
<div class="member-description">
|
||||
<p>
|
||||
Avec une vision claire et un engagement sans faille, il a toujours été présent pour veiller à la bonne
|
||||
réalisation du projet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<img class="member-profile-picture"
|
||||
src="/images/hutopymedia/tospage/membersPictures/profileMarco.png"
|
||||
alt="Marc-Olivier Hébert">
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="member-name">Marc-Olivier Hébert</div>
|
||||
<div class="member-title">Directeur de l’innovation et de la vision</div>
|
||||
<div class="member-description">
|
||||
<p>
|
||||
Avec une vision avant-gardiste, il permet à l’équipe d’explorer de nouvelles idées ou de les réinventer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<img class="member-profile-picture"
|
||||
src="/images/hutopymedia/tospage/membersPictures/profileChloe.png"
|
||||
alt="Chloé Beaugrand">
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="member-name">Chloé Beaugrand</div>
|
||||
<div class="member-title">Directrice marketing</div>
|
||||
<div class="member-description">
|
||||
<p>
|
||||
Elle façonne l'image d’Hutopy et engage notre communauté à travers des campagnes
|
||||
innovantes et impactantes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<img class="member-profile-picture"
|
||||
src="/images/hutopymedia/tospage/membersPictures/Jonathan.png"
|
||||
alt="Édouard Letarte">
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="member-name">Jonathan Bourdon</div>
|
||||
<div class="member-title">Directeur des technologies</div>
|
||||
<div class="member-description">
|
||||
<p>
|
||||
Son expérience d'architecte senior nous permet de développer un logiciel avec
|
||||
une durabilité qui nous permettra de nous développer pendant de longues années.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Chez Hutopy, nous sommes plus qu'une plateforme ; nous sommes une famille dédiée à la
|
||||
réussite de nos créateurs. Nous vous invitons à nous joindre dans cette aventure
|
||||
passionnante, à partager votre créativité et votre expertise avec le monde et à faire
|
||||
d’Hutopy votre utopie. Merci de faire partie de notre histoire.
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
|
||||
.members {
|
||||
@apply mb-12;
|
||||
@apply flex flex-wrap;
|
||||
@apply w-full justify-center;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply flex flex-col;
|
||||
@apply bg-hPrimary rounded-2xl text-hOnPrimary;
|
||||
@apply font-sans text-base;
|
||||
@apply p-2;
|
||||
@apply w-72;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply w-full
|
||||
}
|
||||
|
||||
.member-profile-picture {
|
||||
@apply rounded-t-xl;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
@apply font-bold text-2xl;
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.member-title {
|
||||
@apply font-semibold text-lg;
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.member-description {
|
||||
@apply font-sans text-lg text-justify;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -1,275 +0,0 @@
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<h1>Politique de Contenu</h1>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
|
||||
<p>
|
||||
Hutopy vise à offrir une plateforme sécurisée, inclusive et respectueuse où les créateurs peuvent partager leur
|
||||
travail et interagir avec une communauté engagée. Pour maintenir cet environnement, nous avons établi des lignes
|
||||
directrices claires concernant le type de contenu autorisé sur notre plateforme. En utilisant Hutopy, vous
|
||||
acceptez
|
||||
de respecter cette politique de contenu.
|
||||
</p>
|
||||
|
||||
<h2>Contenu Autorisé</h2>
|
||||
|
||||
<ul>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
Hutopy encourage la publication de contenu créatif, éducatif et inspirant dans divers formats, y compris :
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Arts visuels et design</b>
|
||||
Illustrations, photographies, designs graphiques respectant le droit d'auteur.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Éducation et apprentissage</b>
|
||||
Tutoriels, cours en ligne, webinaires qui favorisent l'apprentissage et le développement personnel.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Contenu écrit</b>
|
||||
Articles, blogs, poésies qui enrichissent les discussions et partagent des connaissances.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Multimédia</b>
|
||||
Vidéos, podcasts et musique originales qui respectent les droits d'auteur et encouragent l'expression
|
||||
créative.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<h2>Contenu Interdit</h2>
|
||||
|
||||
<ul>
|
||||
|
||||
<p>
|
||||
Pour protéger notre communauté, certains types de contenu ne sont pas autorisés sur Hutopy, incluant mais non
|
||||
limité à :
|
||||
</p>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Contenu illégal</b>
|
||||
Tout contenu promouvant des activités illégales ou fournissant des instructions pour
|
||||
commettre des actes illégaux.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Harcèlement et discours de haine</b>
|
||||
Contenu visant à harceler, menacer, ou promouvoir la haine contre des individus ou des groupes basés sur la
|
||||
race, l'ethnie, la religion, le genre, l'orientation sexuelle, l'identité degenre ou tout autre caractère
|
||||
distinctif.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Contenu pour adultes</b>
|
||||
Matériel pornographique ou explicitement sexuel.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Violence et contenu graphique</b>
|
||||
Images ou descriptions de violence excessive, gore ou choquantes.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Publicité mensongère et spam</b>
|
||||
Contenu trompeur, frauduleux ou spammy.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<h2>Droits d'Auteur et Propriété Intellectuelle</h2>
|
||||
|
||||
<p>
|
||||
Respect des Droits : Vous devez posséder les droits sur le contenu que vous publiez sur Hutopy ou avoir
|
||||
l'autorisation expresse du détenteur des droits pour utiliser ce contenu.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Attribution : Lorsque vous utilisez ou adaptez le contenu protégé par des droits d'auteur appartenant à autrui,
|
||||
une
|
||||
attribution claire et correcte doit être fournie.
|
||||
</p>
|
||||
|
||||
<h2>Modération et Signalement</h2>
|
||||
|
||||
<p>
|
||||
<b>Modération</b>
|
||||
Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et évaluer le contenu
|
||||
publié, garantissant le respect de cette politique.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Signalement</b>
|
||||
Les utilisateurs de Hutopy sont encouragés à signaler tout contenu qu'ils considèrent comme enfreignant notre
|
||||
politique de contenu via les outils de signalement disponibles sur la plateforme.
|
||||
</p>
|
||||
|
||||
<h2>Conséquences des Violations</h2>
|
||||
|
||||
<p>
|
||||
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression
|
||||
du contenu ou à la suspension, voire à la résiliation du compte utilisateur.
|
||||
</p>
|
||||
|
||||
<h2>Révisions de la Politique</h2>
|
||||
|
||||
<p>
|
||||
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements dans
|
||||
nos pratiques ou pour se conformer à de nouvelles réglementations légales.
|
||||
</p>
|
||||
|
||||
<h2>Dans le cas d’une non conformité aux politiques de contenus</h2>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
<b>Suspension des Fonds</b>
|
||||
Les montants accumulés sur le compte de l'utilisateur en question seront suspendus temporairement le temps de
|
||||
l'évaluation.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<b>Redistribution à des Œuvres de Charité</b>
|
||||
Si, après évaluation, le contenu est définitivement jugé non conforme à nos clauses de conformité, les fonds
|
||||
suspendus seront redistribués à des œuvres de charité choisies par Hutopy. L'utilisateur concerné sera informé
|
||||
de cette décision et des raisons de la non-conformité de son contenu.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
Cette mesure vise à renforcer la responsabilité des créateurs quant au type de contenu partagé sur Hutopy,
|
||||
tout
|
||||
en soutenant des causes bénéfiques en cas de violation de nos directives.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Engagement d’Hutopy</h2>
|
||||
|
||||
<p>
|
||||
Hutopy s'engage fermement à maintenir une plateforme sûre et respectueuse pour tous ses utilisateurs. Nous
|
||||
prenons
|
||||
une position intransigeante contre toute forme d'exploitation humaine et nous travaillons activement pour
|
||||
prévenir, identifier et combattre les comportements et contenus exploitants. Notre mission est de créer un
|
||||
environnement où la créativité et l'expression personnelle peuvent s'épanouir sans crainte d'exploitation ou
|
||||
d'abus.
|
||||
</p>
|
||||
|
||||
<h2>Politique de Tolérance Zéro</h2>
|
||||
|
||||
<p>
|
||||
Nous appliquons une politique de tolérance zéro à l'égard de :
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>Exploitation sexuelle : Cela inclut, mais n'est pas limité à, la pornographie infantile, le trafic sexuel,
|
||||
et
|
||||
le harcèlement sexuel.
|
||||
</li>
|
||||
<li>
|
||||
Travail forcé : Nous nous opposons à toute forme de travail forcé ou de servitude, y compris le travail des
|
||||
enfants.
|
||||
</li>
|
||||
<li>
|
||||
Exploitation financière : Cela comprend les arnaques, la fraude et tout autre type d'exploitation financière.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Politique de Tolérance Zéro et Signalement/Actions</h2>
|
||||
<ul>
|
||||
<li>Mécanismes de Signalement : Hutopy fournit des outils faciles à utiliser pour signaler rapidement tout
|
||||
contenu
|
||||
ou comportement suspect d'exploitation. Nous encourageons vivement les utilisateurs à utiliser ces outils
|
||||
s'ils
|
||||
rencontrent ou soupçonnent des cas d'exploitation.
|
||||
</li>
|
||||
<li>Réponse Rapide : Notre équipe dédiée examine tous les signalements avec la plus grande attention et prend
|
||||
des
|
||||
mesures immédiates pour adresser les problèmes signalés. Cela peut inclure la suppression de contenu, la
|
||||
suspension de comptes, et, si nécessaire, le signalement aux autorités compétentes.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Collaboration avec les Autorités</h2>
|
||||
<p>
|
||||
Nous collaborons étroitement avec les autorités et les organisations spécialisées pour combattre l'exploitation
|
||||
sous
|
||||
toutes ses formes. Hutopy est déterminé à respecter toutes les lois applicables et à coopérer avec les autorités
|
||||
dans leurs efforts de lutte contre l'exploitation et l'abus.
|
||||
</p>
|
||||
|
||||
<h2>Engagements des Utilisateurs</h2>
|
||||
<p>
|
||||
En rejoignant Hutopy, les utilisateurs s'engagent à respecter nos principes anti-exploitation et à contribuer à
|
||||
la
|
||||
création d'un espace sûr pour tous. Tout manquement à ces engagements entraînera des conséquences sérieuses,
|
||||
conformément à notre politique de tolérance zéro.
|
||||
</p>
|
||||
|
||||
<h2>Modération et Signalement</h2>
|
||||
<ul>
|
||||
<li>Modération : Hutopy utilise à la fois des modérateurs humains et des outils automatisés pour surveiller et
|
||||
évaluer le contenu publié, garantissant le respect de cette politique.
|
||||
</li>
|
||||
<li>Signalement : Les utilisateurs d’Hutopy sont encouragés à signaler tout contenu qu'ils considèrent comme
|
||||
enfreignant notre politique de contenu via les outils de signalement disponibles sur la plateforme.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Conséquences des Violations</h2>
|
||||
<p>
|
||||
La violation de notre politique de contenu peut entraîner des actions allant de l'avertissement à la suppression
|
||||
du
|
||||
contenu ou à la suspension, voire à la résiliation du compte utilisateur.
|
||||
</p>
|
||||
|
||||
<h2>Révisions de la Politique</h2>
|
||||
<p>
|
||||
Hutopy se réserve le droit de modifier cette politique de contenu à tout moment pour refléter les changements
|
||||
dans
|
||||
nos pratiques ou pour se conformer à de nouvelles réglementations légales.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Si vous avez des questions sur cette politique de contenu ou sur la manière dont nous l'appliquons, veuillez
|
||||
contacter notre équipe d'assistance à <a href="mailto:support@hutopy.com">support@hutopy.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
|
||||
<h1>Guide pour les Créateurs</h1>
|
||||
|
||||
<h2>Bienvenue dans la Communauté de Créateurs d’Hutopy</h2>
|
||||
|
||||
<p>
|
||||
Félicitations pour avoir choisi Hutopy pour partager votre créativité et votre savoir ! Ce guide est conçu pour
|
||||
vous aider à maximiser votre présence sur la plateforme, à engager votre audience et à tirer le meilleur parti des
|
||||
outils à votre disposition.
|
||||
</p>
|
||||
|
||||
<h2>Création de Votre Profil de Créateur</h2>
|
||||
|
||||
<p>
|
||||
<b>Personnalisez Votre Profil</b>
|
||||
Ajoutez une photo de profil, une bannière et une bio qui reflète votre personnalité et
|
||||
votre marque de créateur.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Liens et Contacts</b>
|
||||
Intégrez des liens vers vos autres plateformes sociales.
|
||||
</p>
|
||||
|
||||
<h2>Publication de Contenu</h2>
|
||||
|
||||
<p>
|
||||
<b>Diversifiez Votre Contenu</b>
|
||||
Explorez différents formats – vidéos, articles, podcasts – pour captiver divers segments d'audience.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Planification et Consistance</b>
|
||||
Publiez régulièrement pour garder votre audience engagée. Utilisez l'outil de planification d’Hutopy pour organiser
|
||||
vos publications à l'avance.
|
||||
</p>
|
||||
|
||||
<h2>Engagement avec Votre Audience</h2>
|
||||
|
||||
<p>
|
||||
<b>Interagissez</b>
|
||||
Répondez aux commentaires, participez à des discussions et créez des sondages pour encourager l'interaction.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Analysez Vos Performances</b>
|
||||
Utilisez les outils d'analyse d’Hutopy pour comprendre ce qui résonne avec votre audience et ajustez votre
|
||||
stratégie en conséquence.
|
||||
</p>
|
||||
|
||||
<h2>Monétisation</h2>
|
||||
|
||||
<p>
|
||||
<b>Explorez les Options</b>
|
||||
Hutopy offre plusieurs voies de monétisation, y compris les abonnements payants, les dons et le programme
|
||||
d'ambassadeur. Choisissez ce qui convient le mieux à votre contenu et à votre audience.
|
||||
</p>
|
||||
|
||||
<h2>Croissance et Développement</h2>
|
||||
|
||||
<p>
|
||||
<b>Continuez à Apprendre</b>
|
||||
Utilisez le Centre de Ressources Éducatives d’Hutopy pour améliorer vos compétences et rester à jour sur
|
||||
les tendances du secteur. (À venir)
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup>
|
||||
import Footer from "@/views/main/Footer.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen w-full max-w-[1024px] mx-auto bg-hSurface text-hOnSurface p-6">
|
||||
<router-view></router-view>
|
||||
<div>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
|
||||
<h1>Foire Aux Questions</h1>
|
||||
|
||||
<p>
|
||||
La section FAQ de Hutopy est votre ressource essentielle pour trouver des réponses rapides aux questions les plus
|
||||
fréquemment posées sur notre plateforme. Explorez nos réponses détaillées pour optimiser votre utilisation de
|
||||
Hutopy et résoudre vos problèmes en un instant. Consultez régulièrement notre FAQ pour rester informé des
|
||||
dernières fonctionnalités.
|
||||
</p>
|
||||
|
||||
<h2>Comment puis-je créer un compte sur Hutopy?</h2>
|
||||
|
||||
<p>
|
||||
Créer un compte est simple! Visitez notre page d'inscription, remplissez les informations requises, et suivez les
|
||||
instructions pour confirmer votre adresse e-mail ou vous connecter via les partenaires de connexion. Vous pourrez
|
||||
commencer à explorer et à interagir avec la communauté Hutopy immédiatement après.
|
||||
</p>
|
||||
|
||||
<h2>Comment Hutopy rémunère-t-il les créateurs de contenu ?</h2>
|
||||
|
||||
<p>
|
||||
Les créateurs peuvent monétiser leur contenu de plusieurs façons des
|
||||
dons de la part des utilisateurs.
|
||||
</p>
|
||||
|
||||
<h2>Comment puis-je modifier mon profil?</h2>
|
||||
|
||||
<p>
|
||||
Connectez-vous à votre compte, accédez à votre profil, puis cliquez sur "Éditer le profil" pour modifier vos
|
||||
informations, ajouter une bio, changer votre photo de profil, et plus encore.
|
||||
</p>
|
||||
|
||||
<h2>Est-il possible de supprimer mon compte?</h2>
|
||||
|
||||
<p>
|
||||
Oui, vous pouvez faire la suppression de votre compte sur votre profil dans la section plus. Notez que cette
|
||||
action est irréversible.
|
||||
</p>
|
||||
|
||||
<h2>Que faire si j'oublie mon mot de passe?</h2>
|
||||
|
||||
<p>
|
||||
Sur la page de connexion, cliquez sur "Mot de passe oublié ?" et suivez les instructions pour réinitialiser votre
|
||||
mot de passe via votre adresse courriel.
|
||||
</p>
|
||||
|
||||
<h2>Comment signaler un contenu inapproprié?</h2>
|
||||
|
||||
<p>
|
||||
Si vous rencontrez du contenu qui viole nos directives, cliquer sur les trois petits points en haut de la
|
||||
publication et cliquez sur le bouton "Signaler" associé au contenu en question pour alerter notre équipe de
|
||||
modération.
|
||||
</p>
|
||||
|
||||
<h2>Comment puis-je contacter le support Hutopy?</h2>
|
||||
|
||||
<p>
|
||||
Pour toute assistance, vous pouvez nous contacter via notre formulaire en ligne ou par e-mail à
|
||||
support@hutopy.com, ou via nos réseaux sociaux. Notre équipe s'efforce de répondre rapidement à toutes les
|
||||
demandes.
|
||||
</p>
|
||||
|
||||
<h2>Quels sont les frais pour les créateurs sur Hutopy?</h2>
|
||||
|
||||
<p>
|
||||
Hutopy prélève une commission de 12% + 0,30$ sur chaque transaction réalisée sur la plateforme, que ce soit pour
|
||||
les abonnements, les dons ou tout autre revenu généré par les créateurs. Cette commission nous aide à couvrir les
|
||||
coûts de maintenance de la plateforme, de la bande passante, d'assistance utilisateur, des frais de transaction de
|
||||
Stripe et le développement continu pour améliorer votre expérience sur Hutopy.
|
||||
</p>
|
||||
|
||||
<h2>Y a-t-il des frais pour s'inscrire ou pour maintenir mon compte sur Hutopy?</h2>
|
||||
|
||||
<p>
|
||||
Non, l'inscription sur Hutopy est gratuite, et il n'y a pas de frais mensuels ou annuels pour maintenir votre
|
||||
compte. Vous pouvez commencer à utiliser Hutopy et à partager votre contenu sans aucun coût initial.
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
|
||||
<h1>Aide et contact</h1>
|
||||
|
||||
<p>
|
||||
Bienvenue dans notre espace d'assistance ! Que vous soyez un créateur à la recherche de conseils pour optimiser
|
||||
votre présence sur Hutopy, ou un utilisateur curieux d'en apprendre plus sur notre plateforme, vous êtes au bon
|
||||
endroit. Notre objectif est de vous fournir tout le soutien nécessaire pour que votre expérience sur Hutopy soit
|
||||
aussi enrichissante et agréable que possible.
|
||||
</p>
|
||||
|
||||
<h2>Foire Aux Questions</h2>
|
||||
|
||||
<p>
|
||||
Retrouvez les réponses aux questions les plus fréquemment posées concernant l'utilisation d’Hutopy, les
|
||||
fonctionnalités de la plateforme, les options de monétisation, et plus encore. Consulter la <a href="FAQ" style="color: #a30e79;">FAQ</a>
|
||||
</p>
|
||||
|
||||
<h2>Contactez-Nous</h2>
|
||||
|
||||
<p>
|
||||
Nous sommes toujours ravis d'entendre nos utilisateurs ! Que ce soit pour partager vos retours, poser une question
|
||||
spécifique, ou demander des renseignements sur des partenariats, n'hésitez pas à nous contacter.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Par E-mail : <a href="mailto:info@hutopy.com" style="color: #a30e79;">
|
||||
info@hutopy.com</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Réseaux Sociaux : Nous sommes actifs sur
|
||||
<a href="https://www.facebook.com/Hutopy" style="color: #a30e79;">Facebook</a>
|
||||
et <a href="https://www.instagram.com/hutopy.inc" style="color: #a30e79;">Instagram</a>
|
||||
</li>
|
||||
|
||||
<li>Suivez-nous pour rester informé et interagir avec notre communauté.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Assistance Technique</h2>
|
||||
|
||||
<p>
|
||||
Rencontrez-vous un problème technique ?
|
||||
Notre équipe d'assistance est là pour vous aider : <a href="mailto:support@hutopy.com" style="color: #a30e79;">support@hutopy.com</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Nous sommes là pour rendre votre expérience sur Hutopy aussi fluide et positive que possible. N'hésitez pas à nous
|
||||
contacter pour toute aide supplémentaire !
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<h1>Frais</h1>
|
||||
|
||||
<p>
|
||||
Hutopy ne prend que 9,1 % de commission sur vos transactions — notre unique façon de soutenir la plateforme.
|
||||
Chaque dollar prélevé est intégralement réinvesti pour développer des fonctionnalités innovantes, maintenir une
|
||||
infrastructure technologique de pointe et offrir un support utilisateur irréprochable. Ce modèle nous permet
|
||||
d’apporter, dans un avenir très proche, des outils uniques qui vous aideront à vous démarquer encore davantage
|
||||
et à vivre pleinement de votre passion.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Pour chaque transaction, un frais minime assure la sécurité et la fiabilité des paiements, grâce à un partenaire de
|
||||
confiance mondialement reconnu. Ce dernier sécurise des milliards en transactions chaque année pour une diversité
|
||||
d'entreprises, à des entreprises en démarrage aux conglomérats établis. Ce gage de sécurité est disponible pour une
|
||||
somme de 2,9 % plus un 0,30 $ par transaction, une petite contribution pour la tranquillité d'esprit et la
|
||||
protection de vos revenus.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Notre modèle tarifaire, pensé pour la simplicité et la transparence, a pour ambition ultime d'optimiser vos gains.
|
||||
Chez Hutopy, la notion de partenariat prend tout son sens : votre épanouissement est au cœur de nos préoccupations.
|
||||
Bénéficiez d'une plateforme qui élargit votre horizon créatif et entrepreneurial, tout en vous assurant que vos
|
||||
intérêts et ceux de vos donnateurs sont précieusement gardés.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Hutopy est plus qu'une plateforme ; c'est une communauté où la transformation de la passion en profit devient
|
||||
réalité, grâce au soutien indéfectible d'une équipe dévouée à enrichir votre parcours. Nous vous invitons à nous
|
||||
rejoindre pour explorer ensemble les avenues de succès, tout en vous garantissant une part conséquente de vos
|
||||
revenus. Embarquez dans une aventure où votre présence en ligne ne connaît pas de limites, soutenue par Hutopy,
|
||||
votre allié dans la quête du succès.
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
|
||||
<h1>Conditions générales</h1>
|
||||
|
||||
<h2>Bienvenue sur Hutopy</h2>
|
||||
|
||||
<p>
|
||||
En accédant à la plateforme Hutopy et en l'utilisant, vous acceptez de vous conformer aux conditions générales
|
||||
d'utilisation suivantes, qui sont conçues pour assurer une expérience sûre, respectueuse et positive pour tous les
|
||||
utilisateurs. Ces conditions s'appliquent à tous les visiteurs, utilisateurs et autres personnes qui accèdent ou
|
||||
utilisent le service.
|
||||
</p>
|
||||
|
||||
<h2>Utilisation Acceptable</h2>
|
||||
|
||||
<p>
|
||||
1. Contenu : Vous vous engagez à ne pas publier de contenu illégal, diffamatoire, abusif, pornographique, haineux,
|
||||
raciste ou de toute autre nature susceptible de causer du tort. Tout contenu publié reste sous votre responsabilité.
|
||||
</p>
|
||||
<p>
|
||||
2. Comportement : Tout comportement visant à nuire à d'autres utilisateurs, à la plateforme ou à ses opérations est
|
||||
strictement interdit. Cela inclut le piratage, la diffusion de logiciels malveillants et les tentatives
|
||||
d'hameçonnage.
|
||||
</p>
|
||||
|
||||
<h2>Droits de Propriété Intellectuelle</h2>
|
||||
|
||||
<p>
|
||||
Le contenu publié sur Hutopy par les utilisateurs reste la propriété de leurs créateurs respectifs. En publiant du
|
||||
contenu sur Hutopy, vous accordez à la plateforme une licence non exclusive, transférable, libre de droits et
|
||||
mondiale pour utiliser, reproduire, modifier, publier, traduire et distribuer ce contenu dans tout média.
|
||||
</p>
|
||||
|
||||
<h2>Confidentialité</h2>
|
||||
|
||||
<p>
|
||||
La protection de vos données personnelles est de la plus haute importance pour Hutopy. Votre information est
|
||||
collectée et utilisée conformément à notre politique de confidentialité.
|
||||
</p>
|
||||
|
||||
<h2>Limitation de Responsabilité</h2>
|
||||
|
||||
<p>
|
||||
Hutopy et ses affiliés ne seront pas responsables des dommages indirects, accidentels, spéciaux, consécutifs ou
|
||||
punitifs, y compris sans limitation, la perte de profits, de données ou d'usage, que ce soit dans une action
|
||||
contractuelle, délictuelle y compris la négligence ou autre, découlant de ou en relation avec l'accès ou
|
||||
l'utilisation de la plateforme Hutopy.
|
||||
</p>
|
||||
|
||||
<h2>Clause de Non-Poursuite</h2>
|
||||
|
||||
<p>
|
||||
En acceptant ces conditions générales d'utilisation, vous convenez qu'en aucun cas Hutopy, ses dirigeants, employés,
|
||||
partenaires, agents, fournisseurs ou affiliés ne pourront être tenus responsables de dommages directs, indirects,
|
||||
accidentels, spéciaux, consécutifs ou exemplaires résultant de votre utilisation de la plateforme Hutopy. Par
|
||||
conséquent, vous renoncez expressément à tout droit de poursuivre Hutopy et ses affiliés pour toute réclamation liée
|
||||
à votre utilisation de la plateforme.
|
||||
</p>
|
||||
|
||||
<h2>Gestion du Contenu Inapproprié et Sanctions Financières</h2>
|
||||
|
||||
<p>
|
||||
Hutopy s'engage à maintenir un environnement sûr et respectueux pour tous ses utilisateurs. Ainsi, tout contenu
|
||||
publié sur la plateforme est sujet à une évaluation de conformité avec nos directives et nos standards éthiques.
|
||||
Dans l'éventualité où le contenu d'un utilisateur est jugé inapproprié, offensant ou en violation avec nos
|
||||
Acceptation des Conditions
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Votre accès et votre utilisation continue de la plateforme Hutopy constituent votre acceptation des présentes
|
||||
conditions générales et de toutes les modifications futures. Il est de votre responsabilité de vous tenir informé
|
||||
des mises à jour de ces conditions.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Nous vous encourageons à utiliser Hutopy de manière responsable et conforme à nos directives, afin de contribuer à
|
||||
une communauté positive et enrichissante pour tous.
|
||||
</p>
|
||||
|
||||
<h2>Modifications des Conditions</h2>
|
||||
|
||||
<p>
|
||||
Hutopy se réserve le droit de modifier ou de remplacer ces conditions à tout moment. Il est de votre responsabilité
|
||||
de revoir régulièrement ces conditions pour vous tenir informé des mises à jour.
|
||||
</p>
|
||||
|
||||
<h2>Résiliation</h2>
|
||||
|
||||
<p>
|
||||
Hutopy peut résilier ou suspendre votre accès à la plateforme immédiatement, sans préavis ni responsabilité, pour
|
||||
quelque raison que ce soit, y compris, sans limitation, si vous violez les conditions.
|
||||
</p>
|
||||
|
||||
<h2>Loi Applicable</h2>
|
||||
|
||||
<p>
|
||||
Ces conditions seront régies et interprétées conformément aux lois du pays/juridiction où est basée la plateforme,
|
||||
sans égard à ses conflits de dispositions légales.
|
||||
</p>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/views/documentation/documentation.css';
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
h1 {
|
||||
@apply text-hOnBackground;
|
||||
@apply flex items-center uppercase;
|
||||
@apply font-sans font-bold text-4xl md:text-8xl;
|
||||
@apply tracking-widest;
|
||||
@apply mb-12;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-hOnBackground;
|
||||
@apply font-sans font-bold text-2xl md:text-4xl;
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-hOnBackground;
|
||||
@apply font-sans font-normal text-lg;
|
||||
@apply tracking-normal;
|
||||
@apply mb-6;
|
||||
@apply text-justify;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply mb-6;
|
||||
}
|
||||
356
frontend/src/views/main/AppBar.vue
Normal file
356
frontend/src/views/main/AppBar.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore.js';
|
||||
import {
|
||||
mdiChevronDown,
|
||||
mdiCogOutline,
|
||||
mdiLogin,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
|
||||
const props = defineProps({
|
||||
showBrand: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
collapseBrand: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isWorkspaceMenuOpen = ref(false);
|
||||
const workspaceMenuRef = ref(null);
|
||||
|
||||
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
|
||||
const canManageWorkspaces = computed(() => authStore.isManager);
|
||||
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
|
||||
const activeWorkspaceName = computed(() =>
|
||||
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
|
||||
);
|
||||
const appBarActions = computed(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (route.name) {
|
||||
case 'workspace-dashboard':
|
||||
case 'content-items':
|
||||
return authStore.isManager || authStore.isProvider
|
||||
? [{
|
||||
key: 'create-content',
|
||||
label: t('contentItems.newItem'),
|
||||
icon: mdiPlus,
|
||||
route: { name: 'content-item-create' },
|
||||
}]
|
||||
: [];
|
||||
case 'campaigns':
|
||||
return [{
|
||||
key: 'create-campaign',
|
||||
label: t('projects.newProject'),
|
||||
icon: mdiPlus,
|
||||
route: { name: 'campaigns', query: { create: 'true' } },
|
||||
}];
|
||||
case 'channels':
|
||||
return [{
|
||||
key: 'create-channel',
|
||||
label: t('channels.createTitle'),
|
||||
icon: mdiPlus,
|
||||
route: { name: 'channels', query: { create: 'true' } },
|
||||
}];
|
||||
case 'workspace-settings':
|
||||
case 'settings-user-information':
|
||||
case 'settings-workspaces':
|
||||
case 'settings-integrations':
|
||||
return [{
|
||||
key: 'settings',
|
||||
label: t('nav.settings'),
|
||||
icon: mdiCogOutline,
|
||||
route: { name: 'settings-user-information' },
|
||||
}];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function toggleWorkspaceMenu() {
|
||||
if (!canOpenWorkspaceMenu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
|
||||
}
|
||||
|
||||
function chooseWorkspace(workspaceId) {
|
||||
workspaceStore.setActiveWorkspace(workspaceId);
|
||||
isWorkspaceMenuOpen.value = false;
|
||||
}
|
||||
|
||||
async function openCreateWorkspace() {
|
||||
isWorkspaceMenuOpen.value = false;
|
||||
await router.push({ name: 'workspace-create' });
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
|
||||
isWorkspaceMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="side-container">
|
||||
<div class="brand-block">
|
||||
<router-link
|
||||
v-if="showBrand"
|
||||
class="brand-link"
|
||||
:class="{ 'brand-link-collapsed': collapseBrand }"
|
||||
to="/"
|
||||
>
|
||||
<span class="brand-mark">S</span>
|
||||
<div v-if="!collapseBrand">
|
||||
<div class="brand-name">Socialize</div>
|
||||
<div class="brand-caption">{{ t('nav.brandCaption') }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="side-menu">
|
||||
<div class="side-menu-items side-menu-left">
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
ref="workspaceMenuRef"
|
||||
class="user-menu-wrap"
|
||||
>
|
||||
<button
|
||||
class="menu-item-action workspace-trigger"
|
||||
:class="{ 'workspace-trigger-static': !canOpenWorkspaceMenu }"
|
||||
@click.stop="toggleWorkspaceMenu"
|
||||
>
|
||||
<span class="workspace-trigger-mark">W</span>
|
||||
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
|
||||
<v-icon
|
||||
v-if="canOpenWorkspaceMenu"
|
||||
:icon="mdiChevronDown"
|
||||
class="user-trigger-icon"
|
||||
:class="{ 'user-trigger-icon-open': isWorkspaceMenuOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isWorkspaceMenuOpen"
|
||||
class="user-menu"
|
||||
>
|
||||
<button
|
||||
v-for="workspace in workspaceStore.workspaces"
|
||||
:key="workspace.id"
|
||||
class="user-menu-item"
|
||||
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
|
||||
@click="chooseWorkspace(workspace.id)"
|
||||
>
|
||||
<span>{{ workspace.name }}</span>
|
||||
<small>{{ workspace.timeZone }}</small>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canManageWorkspaces"
|
||||
class="user-menu-item user-menu-item-create"
|
||||
type="button"
|
||||
@click="openCreateWorkspace"
|
||||
>
|
||||
<span>{{ t('workspaceSelector.createAction') }}</span>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-menu-items side-menu-right">
|
||||
<template v-if="!authStore.isAuthenticated">
|
||||
<router-link to="/login">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiLogin" />
|
||||
<span class="label">{{ t('nav.signIn') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<router-link
|
||||
v-for="action in appBarActions"
|
||||
:key="action.key"
|
||||
:to="action.route"
|
||||
class="menu-action-link"
|
||||
>
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="action.icon" />
|
||||
<span class="label">{{ action.label }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-container {
|
||||
@apply sticky top-0 z-10 flex flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between;
|
||||
background: rgba(255, 250, 242, 0.82);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
@apply flex items-center gap-3 no-underline;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.brand-link-collapsed {
|
||||
@apply gap-0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
|
||||
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
@apply text-lg font-black uppercase tracking-[0.18em];
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.brand-caption {
|
||||
@apply text-xs uppercase tracking-[0.24em];
|
||||
color: #5d6b82;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
@apply flex flex-1 items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.side-menu-items {
|
||||
@apply flex flex-wrap items-center justify-end gap-2;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.side-menu-left {
|
||||
@apply justify-start;
|
||||
}
|
||||
|
||||
.side-menu-right {
|
||||
@apply justify-end;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply hidden text-nowrap md:inline;
|
||||
}
|
||||
|
||||
.menu-item-action {
|
||||
@apply flex h-11 items-center gap-3 rounded-full px-4 transition-colors;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #172033;
|
||||
border: 1px solid rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.menu-item-action:hover {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.menu-item-action i {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
@apply relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.workspace-trigger {
|
||||
@apply max-w-[18rem] pl-2 pr-3;
|
||||
}
|
||||
|
||||
.user-trigger-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.user-trigger-icon-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.workspace-trigger-static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.workspace-trigger-mark {
|
||||
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-black uppercase;
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.16), rgba(239, 68, 68, 0.14));
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.workspace-trigger-label {
|
||||
@apply max-w-[11rem] truncate;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@apply absolute right-0 top-[calc(100%+0.75rem)] flex min-w-[14rem] flex-col gap-1 rounded-[1.25rem] border p-2;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
@apply flex items-center gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold transition-colors;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.user-menu-item-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.user-menu-item-active {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.user-menu-item small {
|
||||
@apply ml-auto text-xs font-medium;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.user-menu-item-create {
|
||||
@apply justify-between border border-dashed;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.menu-action-link {
|
||||
@apply no-underline;
|
||||
}
|
||||
</style>
|
||||
895
frontend/src/views/main/AppSidebar.vue
Normal file
895
frontend/src/views/main/AppSidebar.vue
Normal file
@@ -0,0 +1,895 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useChannelsStore } from '@/stores/channelsStore.js';
|
||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
||||
import { useNotificationsStore } from '@/stores/notificationsStore.js';
|
||||
import { useContentItemsStore } from '@/stores/contentItemsStore.js';
|
||||
import { useProjectsStore } from '@/stores/projectsStore.js';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import {
|
||||
mdiBellOutline,
|
||||
mdiCalendarMonthOutline,
|
||||
mdiChevronDown,
|
||||
mdiCogOutline,
|
||||
mdiFolderOutline,
|
||||
mdiHomeOutline,
|
||||
mdiImageMultipleOutline,
|
||||
mdiLan,
|
||||
mdiMagnify,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
|
||||
const props = defineProps({
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const channelsStore = useChannelsStore();
|
||||
const contentItemsStore = useContentItemsStore();
|
||||
const languageStore = useLanguageStore();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const isUserMenuOpen = ref(false);
|
||||
const isNotificationsOpen = ref(false);
|
||||
const isSearchFocused = ref(false);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const userMenuRef = ref(null);
|
||||
const notificationsRef = ref(null);
|
||||
const searchRef = ref(null);
|
||||
|
||||
const primaryLinks = [
|
||||
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
|
||||
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
|
||||
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
|
||||
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
|
||||
];
|
||||
|
||||
const openSections = ref({
|
||||
channels: false,
|
||||
projects: false,
|
||||
});
|
||||
|
||||
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase());
|
||||
const projectResults = computed(() => {
|
||||
if (!normalizedSearchQuery.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return projectsStore.projects
|
||||
.filter(project => project.name.toLowerCase().includes(normalizedSearchQuery.value))
|
||||
.slice(0, 5)
|
||||
.map(project => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
description: 'Campaign',
|
||||
route: { name: 'campaign-detail', params: { projectId: project.id } },
|
||||
}));
|
||||
});
|
||||
const contentResults = computed(() => {
|
||||
if (!normalizedSearchQuery.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return contentItemsStore.items
|
||||
.filter(item => {
|
||||
const titleMatch = item.title.toLowerCase().includes(normalizedSearchQuery.value);
|
||||
const hashtagMatch = (item.hashtags ?? '').toLowerCase().includes(normalizedSearchQuery.value);
|
||||
return titleMatch || hashtagMatch;
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
description: item.hashtags || item.publicationTargets,
|
||||
route: { name: 'content-item-detail', params: { id: item.id } },
|
||||
}));
|
||||
});
|
||||
const hasSearchResults = computed(() =>
|
||||
projectResults.value.length > 0 || contentResults.value.length > 0
|
||||
);
|
||||
const isSearchOpen = computed(() => isSearchFocused.value && normalizedSearchQuery.value.length > 0);
|
||||
|
||||
const notificationTitleMap = computed(() => ({
|
||||
'approval.requested': t('notifications.events.approvalRequested'),
|
||||
'approval.decision.recorded': t('notifications.events.approvalDecisionRecorded'),
|
||||
'comment.created': t('notifications.events.commentCreated'),
|
||||
'comment.resolved': t('notifications.events.commentResolved'),
|
||||
'content-item.created': t('notifications.events.contentCreated'),
|
||||
'content-item.revision.created': t('notifications.events.revisionCreated'),
|
||||
'content-item.status.updated': t('notifications.events.statusUpdated'),
|
||||
'asset.google-drive-linked': t('notifications.events.assetLinked'),
|
||||
'asset.revision.created': t('notifications.events.assetRevisionCreated'),
|
||||
}));
|
||||
|
||||
function toggleSection(sectionName) {
|
||||
openSections.value[sectionName] = !openSections.value[sectionName];
|
||||
}
|
||||
|
||||
function toggleNotifications() {
|
||||
isNotificationsOpen.value = !isNotificationsOpen.value;
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
if (!props.isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUserMenuOpen.value = !isUserMenuOpen.value;
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
|
||||
languageStore.setLocale(nextLocale);
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
|
||||
async function openProfile() {
|
||||
isUserMenuOpen.value = false;
|
||||
await router.push({ name: 'settings-user-information' });
|
||||
}
|
||||
|
||||
function formatNotificationTitle(notification) {
|
||||
return notificationTitleMap.value[notification.eventType] ?? notification.message;
|
||||
}
|
||||
|
||||
function formatNotificationDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
async function openNotification(notification) {
|
||||
if (!notification.readAt) {
|
||||
await notificationsStore.markAsRead(notification.id);
|
||||
}
|
||||
|
||||
isNotificationsOpen.value = false;
|
||||
|
||||
if (notification.contentItemId) {
|
||||
await router.push({ name: 'content-item-detail', params: { id: notification.contentItemId } });
|
||||
}
|
||||
}
|
||||
|
||||
async function openSearchResult(result) {
|
||||
isSearchFocused.value = false;
|
||||
await router.push(result.route);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
isUserMenuOpen.value = false;
|
||||
authStore.logout();
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
if (searchRef.value && !searchRef.value.contains(event.target)) {
|
||||
isSearchFocused.value = false;
|
||||
}
|
||||
|
||||
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
|
||||
isNotificationsOpen.value = false;
|
||||
}
|
||||
|
||||
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
path => {
|
||||
if (path.startsWith('/app/channels')) {
|
||||
openSections.value.channels = true;
|
||||
}
|
||||
|
||||
if (path.startsWith('/app/campaigns')) {
|
||||
openSections.value.projects = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.isExpanded,
|
||||
isExpanded => {
|
||||
if (!isExpanded) {
|
||||
isUserMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="app-sidebar"
|
||||
:class="{ 'app-sidebar-collapsed': !isExpanded }"
|
||||
>
|
||||
<div class="app-sidebar-inner">
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="sidebar-section sidebar-utilities"
|
||||
>
|
||||
<div
|
||||
ref="searchRef"
|
||||
class="sidebar-search-wrap"
|
||||
>
|
||||
<label
|
||||
class="sidebar-search"
|
||||
:class="{ 'sidebar-search-open': isSearchOpen }"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiMagnify"
|
||||
class="sidebar-search-icon"
|
||||
/>
|
||||
<input
|
||||
v-if="isExpanded"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="sidebar-search-input"
|
||||
placeholder="Search"
|
||||
@focus="isSearchFocused = true"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="isSearchOpen"
|
||||
class="sidebar-floating-panel"
|
||||
>
|
||||
<div
|
||||
v-if="projectResults.length"
|
||||
class="sidebar-search-group"
|
||||
>
|
||||
<strong>Campaigns</strong>
|
||||
<button
|
||||
v-for="result in projectResults"
|
||||
:key="`project-${result.id}`"
|
||||
class="sidebar-search-result"
|
||||
@click="openSearchResult(result)"
|
||||
>
|
||||
<span>{{ result.label }}</span>
|
||||
<small>{{ result.description }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentResults.length"
|
||||
class="sidebar-search-group"
|
||||
>
|
||||
<strong>Content items</strong>
|
||||
<button
|
||||
v-for="result in contentResults"
|
||||
:key="`content-${result.id}`"
|
||||
class="sidebar-search-result"
|
||||
@click="openSearchResult(result)"
|
||||
>
|
||||
<span>{{ result.label }}</span>
|
||||
<small>{{ result.description }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasSearchResults"
|
||||
class="sidebar-search-empty"
|
||||
>
|
||||
No results found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="notificationsRef"
|
||||
class="sidebar-notifications-wrap"
|
||||
>
|
||||
<button
|
||||
class="sidebar-link sidebar-utility-link"
|
||||
type="button"
|
||||
@click.stop="toggleNotifications"
|
||||
>
|
||||
<span class="sidebar-link-main">
|
||||
<span class="sidebar-notification-icon-wrap">
|
||||
<v-icon :icon="mdiBellOutline" />
|
||||
<span
|
||||
v-if="notificationsStore.unreadCount"
|
||||
class="sidebar-notification-badge"
|
||||
>
|
||||
{{ Math.min(notificationsStore.unreadCount, 9) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isExpanded"
|
||||
class="sidebar-link-label"
|
||||
>
|
||||
{{ t('notifications.title') }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isExpanded && isNotificationsOpen"
|
||||
class="sidebar-floating-panel sidebar-notifications-panel"
|
||||
>
|
||||
<div class="sidebar-notifications-header">
|
||||
<strong>{{ t('notifications.title') }}</strong>
|
||||
<span>{{ notificationsStore.unreadCount }} {{ t('notifications.unread') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notificationsStore.isLoading"
|
||||
class="sidebar-notifications-empty"
|
||||
>
|
||||
{{ t('notifications.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="notificationsStore.error"
|
||||
class="sidebar-notifications-empty"
|
||||
>
|
||||
{{ notificationsStore.error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="notification in notificationsStore.recentItems"
|
||||
:key="notification.id"
|
||||
class="sidebar-notification-row"
|
||||
:class="{ 'sidebar-notification-row-unread': !notification.readAt }"
|
||||
@click="openNotification(notification)"
|
||||
>
|
||||
<strong>{{ formatNotificationTitle(notification) }}</strong>
|
||||
<span>{{ notification.message }}</span>
|
||||
<small>{{ formatNotificationDate(notification.createdAt) }}</small>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!notificationsStore.isLoading && !notificationsStore.recentItems.length"
|
||||
class="sidebar-notifications-empty"
|
||||
>
|
||||
{{ t('notifications.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="link in primaryLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="sidebar-link"
|
||||
active-class="sidebar-link-active"
|
||||
:title="!isExpanded ? t(link.labelKey) : null"
|
||||
>
|
||||
<v-icon :icon="link.icon" />
|
||||
<span
|
||||
v-if="isExpanded"
|
||||
class="sidebar-link-label"
|
||||
>
|
||||
{{ t(link.labelKey) }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<router-link
|
||||
to="/app/campaigns"
|
||||
class="sidebar-link sidebar-link-section"
|
||||
active-class="sidebar-link-active"
|
||||
:title="!isExpanded ? t('nav.projects') : null"
|
||||
>
|
||||
<span class="sidebar-link-main">
|
||||
<v-icon :icon="mdiFolderOutline" />
|
||||
<span
|
||||
v-if="isExpanded"
|
||||
class="sidebar-link-label"
|
||||
>
|
||||
{{ t('nav.projects') }}
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isExpanded"
|
||||
to="/app/campaigns?create=true"
|
||||
class="sidebar-section-action"
|
||||
:title="t('projects.createTitle')"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
v-if="isExpanded"
|
||||
class="sidebar-section-toggle"
|
||||
type="button"
|
||||
@click="toggleSection('projects')"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiChevronDown"
|
||||
class="sidebar-chevron"
|
||||
:class="{ 'sidebar-chevron-open': openSections.projects }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpanded && openSections.projects"
|
||||
class="sidebar-sublist"
|
||||
>
|
||||
<router-link
|
||||
to="/app/campaigns"
|
||||
class="sidebar-sublink sidebar-sublink-overview"
|
||||
active-class="sidebar-sublink-active"
|
||||
>
|
||||
<span>{{ t('sidebar.allProjects') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-for="project in projectsStore.projects"
|
||||
:key="project.id"
|
||||
:to="{ name: 'campaign-detail', params: { projectId: project.id } }"
|
||||
class="sidebar-sublink"
|
||||
active-class="sidebar-sublink-active"
|
||||
>
|
||||
<span>{{ project.name }}</span>
|
||||
</router-link>
|
||||
|
||||
<div
|
||||
v-if="!projectsStore.projects.length"
|
||||
class="sidebar-empty"
|
||||
>
|
||||
{{ t('sidebar.noProjects') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<router-link
|
||||
to="/app/channels"
|
||||
class="sidebar-link sidebar-link-section"
|
||||
active-class="sidebar-link-active"
|
||||
:title="!isExpanded ? t('nav.channels') : null"
|
||||
>
|
||||
<span class="sidebar-link-main">
|
||||
<v-icon :icon="mdiLan" />
|
||||
<span
|
||||
v-if="isExpanded"
|
||||
class="sidebar-link-label"
|
||||
>
|
||||
{{ t('nav.channels') }}
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isExpanded"
|
||||
to="/app/channels?create=true"
|
||||
class="sidebar-section-action"
|
||||
:title="t('channels.createTitle')"
|
||||
>
|
||||
<v-icon :icon="mdiPlus" />
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
v-if="isExpanded"
|
||||
class="sidebar-section-toggle"
|
||||
type="button"
|
||||
@click="toggleSection('channels')"
|
||||
>
|
||||
<v-icon
|
||||
:icon="mdiChevronDown"
|
||||
class="sidebar-chevron"
|
||||
:class="{ 'sidebar-chevron-open': openSections.channels }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpanded && openSections.channels"
|
||||
class="sidebar-sublist"
|
||||
>
|
||||
<router-link
|
||||
to="/app/channels"
|
||||
class="sidebar-sublink sidebar-sublink-overview"
|
||||
active-class="sidebar-sublink-active"
|
||||
>
|
||||
<span>{{ t('sidebar.allChannels') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-for="channel in channelsStore.channels"
|
||||
:key="channel.id"
|
||||
:to="{ name: 'channels', query: { channel: channel.id } }"
|
||||
class="sidebar-sublink"
|
||||
active-class="sidebar-sublink-active"
|
||||
>
|
||||
<span>{{ channel.name }}</span>
|
||||
</router-link>
|
||||
|
||||
<div
|
||||
v-if="!channelsStore.channels.length"
|
||||
class="sidebar-empty"
|
||||
>
|
||||
{{ t('sidebar.noChannels') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
ref="userMenuRef"
|
||||
class="sidebar-workspace sidebar-workspace-bottom"
|
||||
>
|
||||
<button
|
||||
class="sidebar-workspace-trigger"
|
||||
type="button"
|
||||
:title="!isExpanded ? userProfileStore.alias : null"
|
||||
@click.stop="toggleUserMenu"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="userProfileStore.alias"
|
||||
:src="userProfileStore.portraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="isExpanded"
|
||||
class="sidebar-workspace-label"
|
||||
>
|
||||
{{ userProfileStore.alias }}
|
||||
</span>
|
||||
<v-icon
|
||||
v-if="isExpanded"
|
||||
:icon="mdiChevronDown"
|
||||
class="sidebar-workspace-icon"
|
||||
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isExpanded && isUserMenuOpen"
|
||||
class="sidebar-workspace-menu"
|
||||
>
|
||||
<button
|
||||
class="sidebar-workspace-option"
|
||||
type="button"
|
||||
@click="openProfile"
|
||||
>
|
||||
{{ t('nav.profile') }}
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-workspace-option"
|
||||
type="button"
|
||||
@click="toggleLanguage"
|
||||
>
|
||||
{{ t('nav.language') }}
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-workspace-option sidebar-workspace-option-danger"
|
||||
type="button"
|
||||
@click="handleLogout"
|
||||
>
|
||||
{{ t('nav.signOut') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-sidebar {
|
||||
@apply w-[19rem] flex-shrink-0 px-4 pb-4 pt-4 transition-[width,padding] duration-200 md:sticky md:top-24 md:h-[calc(100vh-6rem)] md:pt-0;
|
||||
border-right: 1px solid rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.app-sidebar-inner {
|
||||
@apply flex h-full flex-col gap-4 overflow-y-auto py-3 pr-3;
|
||||
}
|
||||
|
||||
.sidebar-utilities {
|
||||
@apply gap-3 pb-1;
|
||||
}
|
||||
|
||||
.sidebar-search-wrap,
|
||||
.sidebar-notifications-wrap {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
@apply flex h-11 items-center gap-3 rounded-[1.1rem] border px-4 transition-colors;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
border-color: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-search-open,
|
||||
.sidebar-search:focus-within {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-search-icon {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.sidebar-search-input {
|
||||
@apply min-w-0 flex-1 border-0 bg-transparent p-0 text-sm;
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-search-input::placeholder {
|
||||
color: #7a8799;
|
||||
}
|
||||
|
||||
.sidebar-floating-panel {
|
||||
@apply absolute left-0 right-0 top-[calc(100%+0.6rem)] z-40 flex flex-col gap-3 rounded-[1.25rem] border p-3;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-search-group {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.sidebar-search-group strong {
|
||||
@apply px-2 text-xs font-black uppercase tracking-[0.18em];
|
||||
color: #5d6b82;
|
||||
}
|
||||
|
||||
.sidebar-search-result {
|
||||
@apply flex flex-col gap-1 rounded-[0.95rem] px-3 py-3 text-left transition-colors;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-search-result:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-search-result small,
|
||||
.sidebar-search-empty {
|
||||
@apply text-xs leading-5;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-search-empty {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
|
||||
.sidebar-utility-link {
|
||||
@apply w-full justify-between text-left;
|
||||
}
|
||||
|
||||
.sidebar-notification-icon-wrap {
|
||||
@apply relative flex items-center justify-center;
|
||||
}
|
||||
|
||||
.sidebar-notification-badge {
|
||||
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
|
||||
background: #ef4444;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.sidebar-notifications-panel {
|
||||
@apply gap-1;
|
||||
}
|
||||
|
||||
.sidebar-notifications-header {
|
||||
@apply mb-1 flex items-center justify-between gap-3 px-3 py-2;
|
||||
}
|
||||
|
||||
.sidebar-notifications-header strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-notifications-header span,
|
||||
.sidebar-notifications-empty,
|
||||
.sidebar-notification-row span,
|
||||
.sidebar-notification-row small {
|
||||
@apply text-xs leading-5;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-notifications-empty {
|
||||
@apply px-3 py-4;
|
||||
}
|
||||
|
||||
.sidebar-notification-row {
|
||||
@apply flex flex-col gap-1 rounded-[0.9rem] px-3 py-3 text-left transition-colors;
|
||||
}
|
||||
|
||||
.sidebar-notification-row:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-notification-row-unread {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-notification-row strong {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-workspace {
|
||||
@apply relative flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.sidebar-workspace-bottom {
|
||||
@apply mt-auto pt-4;
|
||||
border-top: 1px solid rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-workspace-kicker {
|
||||
@apply px-4 text-[10px] font-bold uppercase tracking-[0.22em];
|
||||
color: #7a8799;
|
||||
}
|
||||
|
||||
.sidebar-workspace-trigger {
|
||||
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-workspace-trigger:hover {
|
||||
background: rgba(23, 32, 51, 0.07);
|
||||
}
|
||||
|
||||
.sidebar-workspace-label {
|
||||
@apply flex-1 truncate text-sm font-semibold;
|
||||
}
|
||||
|
||||
.sidebar-workspace-icon {
|
||||
@apply text-base transition-transform;
|
||||
color: #5d6b82;
|
||||
}
|
||||
|
||||
.sidebar-workspace-icon-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar-workspace-menu {
|
||||
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-workspace-option {
|
||||
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-workspace-option:hover {
|
||||
background: rgba(23, 32, 51, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-workspace-option-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
|
||||
color: #44516a;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-link-active {
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
|
||||
color: #172033;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-link-section {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.sidebar-link-main {
|
||||
@apply flex min-w-0 items-center gap-3;
|
||||
}
|
||||
|
||||
.sidebar-link-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.sidebar-section-toggle {
|
||||
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-section-action {
|
||||
@apply flex h-11 w-11 items-center justify-center rounded-[1rem] transition-colors no-underline;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-section-action:hover,
|
||||
.sidebar-section-toggle:hover {
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-chevron {
|
||||
@apply text-base transition-transform;
|
||||
}
|
||||
|
||||
.sidebar-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar-link :deep(.v-icon) {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.sidebar-sublist {
|
||||
@apply flex flex-col gap-1 pl-4;
|
||||
}
|
||||
|
||||
.sidebar-sublink {
|
||||
@apply flex flex-col rounded-[1rem] px-4 py-3 text-sm no-underline transition-colors;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.sidebar-sublink:hover,
|
||||
.sidebar-sublink-active {
|
||||
background: rgba(23, 32, 51, 0.05);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.sidebar-sublink strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.sidebar-sublink small,
|
||||
.sidebar-empty {
|
||||
@apply text-xs;
|
||||
color: #7a8799;
|
||||
}
|
||||
|
||||
.app-sidebar-collapsed {
|
||||
@apply w-[5.5rem] px-3;
|
||||
}
|
||||
|
||||
.app-sidebar-collapsed .sidebar-search {
|
||||
@apply justify-center px-0;
|
||||
}
|
||||
|
||||
.app-sidebar-collapsed .sidebar-floating-panel {
|
||||
left: calc(100% + 0.75rem);
|
||||
right: auto;
|
||||
width: min(22rem, calc(100vw - 7rem));
|
||||
}
|
||||
</style>
|
||||
@@ -1,139 +0,0 @@
|
||||
<script setup>
|
||||
import Instagram from '@/views/svg/Instagram.vue';
|
||||
import Facebook from '@/views/svg/Facebook.vue';
|
||||
import X from '@/views/svg/X.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="flex flex-col gap-10 pt-7 pb-10">
|
||||
<div class="footer-socials">
|
||||
<a
|
||||
href="https://www.facebook.com/profile.php?id=61556819217561"
|
||||
target="_blank"
|
||||
>
|
||||
<facebook class="social-icon"></facebook>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/hutopy.inc/"
|
||||
target="_blank"
|
||||
>
|
||||
<instagram class="social-icon"></instagram>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/Hutopyinc/"
|
||||
target="_blank"
|
||||
>
|
||||
<x class="social-icon"></x>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/helpandcontact"
|
||||
>
|
||||
{{ t('footer.helpandcontact') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/faq"
|
||||
>
|
||||
{{ t('footer.faq') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/guideforcreators"
|
||||
>
|
||||
{{ t('footer.creatorguide') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/termsandconditions"
|
||||
>
|
||||
{{ t('footer.termsandconditions') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/contentpolicy"
|
||||
>
|
||||
{{ t('footer.contentpolicy') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/about"
|
||||
>
|
||||
{{ t('footer.about') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="link"
|
||||
to="/documents/pricing"
|
||||
>
|
||||
{{ t('footer.pricing') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="footer-copyright">
|
||||
Hutopy ©{{ new Date().getFullYear() }} - {{ t('footer.allRightsReserved') }}
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer-socials {
|
||||
@apply flex flex-row justify-center;
|
||||
@apply gap-10;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
@apply flex flex-row flex-wrap justify-center;
|
||||
@apply gap-4 px-4;
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
@apply flex justify-center;
|
||||
@apply text-hOnBackground tracking-widest font-sans text-sm;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
@apply fill-current w-6 h-6;
|
||||
@apply text-hOnBackground;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-hOnBackground;
|
||||
@apply tracking-widest font-sans text-sm;
|
||||
@apply hover:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"footer": {
|
||||
"helpandcontact": "Help & Contact",
|
||||
"faq": "FAQ",
|
||||
"creatorguide": "Creator Guide",
|
||||
"termsandconditions": "Terms & Conditions",
|
||||
"contentpolicy": "Content Policy",
|
||||
"about": "About",
|
||||
"pricing": "Pricing",
|
||||
"allRightsReserved": "All Rights Reserved"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"footer": {
|
||||
"helpandcontact": "Aide & Contact",
|
||||
"faq": "FAQ",
|
||||
"creatorguide": "Guide du Créateur",
|
||||
"termsandconditions": "Conditions Générales",
|
||||
"contentpolicy": "Politique de Contenu",
|
||||
"about": "À Propos",
|
||||
"pricing": "Tarifs",
|
||||
"allRightsReserved": "Tous Droits Réservés"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,309 +1,192 @@
|
||||
<script setup>
|
||||
import Footer from '@/views/main/Footer.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
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>
|
||||
<div>
|
||||
<div class="pa-4 flex flex-col justify-center md:flex-row">
|
||||
<div class="py-6">
|
||||
<div>
|
||||
<img
|
||||
alt="Hutopy Logo"
|
||||
class="md:h-44 logo-image sm:h-28 sm:mx-auto"
|
||||
src="/images/hutopymedia/banners/hutopy.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3 header-btn">
|
||||
<v-btn
|
||||
class="text-white w-full sm:w-auto inscription-btn-header"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('inscription') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="w-full sm:w-auto inscription-btn-header-outlined"
|
||||
to="/create-creator"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ t('createPage') }}
|
||||
</v-btn>
|
||||
<div 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>
|
||||
|
||||
<div class="support-container flex flex-col items-center space-y-4 md:flex-row md:space-y-0 md:space-x-6">
|
||||
<div class="support-text text-justify md:text-left">
|
||||
<span class="text-white">{{ t('support') }}</span>
|
||||
<br />
|
||||
<span class="text-white">{{ t('creators') }}</span>
|
||||
<br />
|
||||
<span class="text-white">{{ t('projects') }}</span>
|
||||
<br />
|
||||
<span class="text-white">{{ t('love') }}</span>
|
||||
<div 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>
|
||||
<img
|
||||
alt="YourHutopy"
|
||||
class="w-48 h-48 md:w-48 md:h-48 object-contain"
|
||||
src="/images/hutopymedia/banners/heart.png"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="relative mt-10">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row justify-center items-center lg:space-x-14 space-y-6 lg:space-y-0 pa-1"
|
||||
<section class="pillars-grid">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="pillar-card"
|
||||
>
|
||||
<div class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
|
||||
<div class="text-xl mb-2 box-text">{{ t('supportText') }}</div>
|
||||
<img
|
||||
alt="YourHutopy"
|
||||
class="max-h-56 mx-auto"
|
||||
src="/images/hutopymedia/homepage/hands.png"
|
||||
/>
|
||||
<div class="text-md text-justify px-6">
|
||||
{{ t('supportDescription') }}
|
||||
</div>
|
||||
<!-- <v-btn>Soutenir</v-btn> -->
|
||||
<div 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 class="bg-hSurface p-4 max-w-md text-center rounded-3xl space-y-8 shadow-xl h-[520px]">
|
||||
<div class="text-xl mb-2 box-text">{{ t('create') }}</div>
|
||||
<img
|
||||
alt="YourHutopy"
|
||||
class="max-h-56 mx-auto"
|
||||
src="/images/hutopymedia/homepage/brain.png"
|
||||
/>
|
||||
<div class="text-md text-justify px-6">
|
||||
{{ t('creatorDescription') }}
|
||||
</div>
|
||||
<v-btn
|
||||
class="inscription-btn"
|
||||
to="/login"
|
||||
>
|
||||
{{ t('signup') }}
|
||||
</v-btn>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-6 py-8">
|
||||
<div class="gap-8 items-start flex flex-col md:flex-row">
|
||||
<!-- Section de texte -->
|
||||
<div class="space-y-6">
|
||||
<img
|
||||
alt="YourHutopy"
|
||||
class="w-full mb-6"
|
||||
src="/images/hutopymedia/homepage/votrehutopy.png"
|
||||
/>
|
||||
<div class="space-y-4">
|
||||
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
|
||||
{{ t('whatIsHutopy') }}
|
||||
</p>
|
||||
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
|
||||
{{ t('hutopyDescription') }}
|
||||
</p>
|
||||
<p class="text-lg leading-relaxed text-justify sm:mx-5 md:mx-1 homepagetext">
|
||||
{{ t('hutopyValues') }}
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<v-btn
|
||||
class="text-white mt-12 flex items-center justify-center round create-btn"
|
||||
to="/create-creator"
|
||||
>
|
||||
{{ t('createPage') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section droite : 4 images -->
|
||||
<div class="mt-8 md:mt-0 grid grid-cols-2 gap-4 lg:ml-15">
|
||||
<div>
|
||||
<img
|
||||
alt="Grinding"
|
||||
class="w-full h-auto object-cover rounded-2xl"
|
||||
src="/images/hutopymedia/homepage/grinding.png"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
alt="Microphone"
|
||||
class="w-full h-auto object-cover rounded-2xl"
|
||||
src="/images/hutopymedia/homepage/sign.png"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
alt="Girl VR"
|
||||
class="w-full h-auto object-cover rounded-2xl"
|
||||
src="/images/hutopymedia/homepage/girlvr.png"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
alt="Girl Army"
|
||||
class="w-full h-auto object-cover rounded-2xl"
|
||||
src="/images/hutopymedia/homepage/girlarmy.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer class="mt-10"></Footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.box-text {
|
||||
color: #6a0164;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
.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;
|
||||
}
|
||||
|
||||
.inscription-btn-header {
|
||||
color: white;
|
||||
background-color: #6a0164;
|
||||
font-size: 18px;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
font-weight: bold;
|
||||
.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);
|
||||
}
|
||||
|
||||
.inscription-btn-header-outlined {
|
||||
color: #6a0164;
|
||||
font-size: 18px;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
font-weight: bold;
|
||||
.hero-copy {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
|
||||
.inscription-btn {
|
||||
color: white;
|
||||
background-color: #6a0164;
|
||||
font-size: 18px;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
font-weight: bold;
|
||||
border-radius: 10px;
|
||||
.hero-copy h1 {
|
||||
@apply max-w-3xl text-4xl font-black leading-tight md:text-6xl;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background-color: #6a0164;
|
||||
font-size: 18px;
|
||||
height: 48px;
|
||||
width: auto;
|
||||
padding: 0 32px;
|
||||
font-weight: bold;
|
||||
border-radius: 10px;
|
||||
.hero-copy p {
|
||||
@apply max-w-2xl text-base leading-7 md:text-lg;
|
||||
color: rgba(255, 250, 242, 0.84);
|
||||
}
|
||||
|
||||
.overlay p {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.26em];
|
||||
color: #ffb26b;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f4f4f4;
|
||||
.hero-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row;
|
||||
}
|
||||
|
||||
.support-container {
|
||||
display: flex;
|
||||
justify-content: center; /* Centre le bloc horizontalement */
|
||||
align-items: center; /* Centre le bloc verticalement (optionnel) */
|
||||
.hero-panel {
|
||||
@apply rounded-[1.5rem] p-6;
|
||||
background: rgba(255, 250, 242, 0.08);
|
||||
border: 1px solid rgba(255, 250, 242, 0.12);
|
||||
}
|
||||
|
||||
.support-text {
|
||||
font-size: 2.2rem; /* Ajustez la taille du texte */
|
||||
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
|
||||
text-align: left; /* Alignement du texte à gauche */
|
||||
font-weight: bold; /* Rend le texte gras */
|
||||
.hero-panel-title {
|
||||
@apply mb-4 text-sm font-bold uppercase tracking-[0.22em];
|
||||
color: #7dd3c7;
|
||||
}
|
||||
|
||||
.support-text .highlight {
|
||||
color: #6a0164; /* Remplacez par la couleur souhaitée */
|
||||
font-weight: bold; /* Mettre en gras */
|
||||
.workflow-list {
|
||||
@apply flex list-decimal flex-col gap-4 pl-5;
|
||||
}
|
||||
|
||||
.highlight2 {
|
||||
color: #b81286; /* Remplacez par la couleur souhaitée */
|
||||
.workflow-list li {
|
||||
@apply text-sm leading-6 md:text-base;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
margin-left: auto;
|
||||
.pillars-grid {
|
||||
@apply grid gap-4 md:grid-cols-3;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.header-btn {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
font-size: 3rem; /* Ajustez la taille du texte */
|
||||
line-height: 1.1; /* Ajustez l'espacement entre les lignes */
|
||||
text-align: left; /* Alignement du texte à gauche */
|
||||
font-weight: bold; /* Rend le texte gras */
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-btn {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
margin-right: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.pillar-card p {
|
||||
@apply mt-3 text-lg font-semibold leading-7;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.homepagetext {
|
||||
color: white;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
.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>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"inscription": "Sign Up",
|
||||
"createPage": "Create Page",
|
||||
"support": "Support",
|
||||
"creators": "Creators",
|
||||
"projects": "Projects",
|
||||
"love": "Love",
|
||||
"supportText": "Support",
|
||||
"supportDescription": "Support your favorite creators and help them grow. Your contributions make a real difference in their creative journey.",
|
||||
"create": "Create",
|
||||
"creatorDescription": "Create your own page and start your creative journey. Share your passion with the world and build your community.",
|
||||
"signup": "Sign Up",
|
||||
"whatIsHutopy": "What is Hutopy?",
|
||||
"hutopyDescription": "Hutopy is a platform that connects creators with their audience. We provide tools and features to help creators monetize their content and build their community.",
|
||||
"hutopyValues": "Our values are centered around creativity, community, and support. We believe in empowering creators to pursue their passions and build sustainable careers."
|
||||
},
|
||||
"fr": {
|
||||
"inscription": "S'inscrire",
|
||||
"createPage": "Créer une Page",
|
||||
"support": "Soutenir",
|
||||
"creators": "Créateurs",
|
||||
"projects": "Projets",
|
||||
"love": "Passion",
|
||||
"supportText": "Soutenir",
|
||||
"supportDescription": "Soutenez vos créateurs préférés et aidez-les à grandir. Vos contributions font une réelle différence dans leur parcours créatif.",
|
||||
"create": "Créer",
|
||||
"creatorDescription": "Créez votre propre page et commencez votre parcours créatif. Partagez votre passion avec le monde et construisez votre communauté.",
|
||||
"signup": "S'inscrire",
|
||||
"whatIsHutopy": "Qu'est-ce que Hutopy ?",
|
||||
"hutopyDescription": "Hutopy est une plateforme qui connecte les créateurs avec leur audience. Nous fournissons des outils et des fonctionnalités pour aider les créateurs à monétiser leur contenu et à construire leur communauté.",
|
||||
"hutopyValues": "Nos valeurs sont centrées sur la créativité, la communauté et le soutien. Nous croyons en l'autonomisation des créateurs pour poursuivre leurs passions et construire des carrières durables."
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
||||
import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const languageStore = useLanguageStore();
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function toggleLanguage() {
|
||||
const languages = ['fr', 'en'];
|
||||
const currentIndex = languages.indexOf(locale.value);
|
||||
const nextIndex = (currentIndex + 1) % languages.length;
|
||||
languageStore.setLocale(languages[nextIndex]);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="side-container">
|
||||
<div class="side-logo">
|
||||
<router-link to="/@hutopy">
|
||||
<img
|
||||
alt="hutopy logo"
|
||||
height="50"
|
||||
src="/images/hutopy-logo.png"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="side-menu">
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="side-menu-portrait"
|
||||
>
|
||||
<img
|
||||
:src="userProfileStore.portraitUrl"
|
||||
alt="Profile Image"
|
||||
class="rounded-full"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span class="profile-label">{{ userProfileStore.alias }}</span>
|
||||
</div>
|
||||
|
||||
<div class="side-menu-items">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link
|
||||
v-if="creatorProfileStore.hasCreator"
|
||||
:to="`/@${creatorProfileStore.creator.slug}`"
|
||||
>
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
to="/create-creator"
|
||||
>
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link to="/profile">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiAccount" />
|
||||
<span class="label">{{ t('sidebar.myProfile') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<button
|
||||
class="menu-item-action"
|
||||
@click="toggleLanguage"
|
||||
>
|
||||
<v-icon :icon="mdiTranslateVariant" />
|
||||
<span class="label">{{ locale }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!authStore.isAuthenticated">
|
||||
<router-link to="/login">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiLogin" />
|
||||
<span class="label">{{ t('sidebar.signIn') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else>
|
||||
<button
|
||||
class="menu-item-action"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<v-icon :icon="mdiLogout" />
|
||||
<span class="label">{{ t('sidebar.signOut') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-container {
|
||||
@apply bg-hSurface text-hOnSurface;
|
||||
@apply flex;
|
||||
@apply max-h-screen;
|
||||
@apply h-16;
|
||||
}
|
||||
|
||||
.side-logo {
|
||||
@apply flex flex-grow;
|
||||
@apply items-center justify-start p-4;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
@apply flex gap-4 p-6;
|
||||
@apply items-center;
|
||||
@apply flex-row-reverse;
|
||||
}
|
||||
|
||||
.side-menu-portrait {
|
||||
@apply w-10 h-10;
|
||||
@apply -ml-1;
|
||||
@apply flex items-center justify-start;
|
||||
}
|
||||
|
||||
.side-menu-items {
|
||||
@apply flex gap-2;
|
||||
@apply flex-row;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
@apply ml-5;
|
||||
@apply text-lg font-sans capitalize;
|
||||
@apply font-semibold;
|
||||
@apply hidden;
|
||||
@apply min-w-40 truncate;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-nowrap;
|
||||
@apply ml-4;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.menu-item-action {
|
||||
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
|
||||
@apply capitalize;
|
||||
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
|
||||
@apply mx-0;
|
||||
@apply lg:pl-2;
|
||||
@apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal;
|
||||
|
||||
i {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"sidebar": {
|
||||
"myPage": "My Page",
|
||||
"myProfile": "My Profile",
|
||||
"signIn": "Sign In",
|
||||
"signOut": "Sign Out"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"sidebar": {
|
||||
"myPage": "Ma Page",
|
||||
"myProfile": "Mon Profil",
|
||||
"signIn": "Se Connecter",
|
||||
"signOut": "Se Déconnecter"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,953 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import SocialsDialog from './creators/SocialsDialog.vue';
|
||||
import AliasDialog from '@/views/profile/account/AliasDialog.vue';
|
||||
import FullnameDialog from '@/views/profile/account/FullnameDialog.vue';
|
||||
import EmailDialog from '@/views/profile/account/EmailDialog.vue';
|
||||
import ChangePasswordDialog from '@/views/profile/account/ChangePasswordDialog.vue';
|
||||
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
|
||||
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
|
||||
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
|
||||
import ChangeTitleDialog from '@/views/profile/creators/ChangeTitleDialog.vue';
|
||||
import ChangePhoneDialog from '@/views/profile/creators/ChangePhoneDialog.vue';
|
||||
import ChangeEmailDialog from '@/views/profile/creators/ChangeEmailDialog.vue';
|
||||
import Youtube from '@/views/svg/Youtube.vue';
|
||||
import Web from '@/views/svg/Web.vue';
|
||||
import Reddit from '@/views/svg/Reddit.vue';
|
||||
import X from '@/views/svg/X.vue';
|
||||
import Linkedin from '@/views/svg/Linkedin.vue';
|
||||
import Tiktok from '@/views/svg/Tiktok.vue';
|
||||
import Instagram from '@/views/svg/Instagram.vue';
|
||||
import Facebook from '@/views/svg/Facebook.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QRCodeVue from 'qrcode.vue';
|
||||
import { mdiCheck, mdiChevronRight, mdiContentCopy, mdiCreditCard, mdiCreditCardOff, mdiDownload } from '@mdi/js';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import hutopyLogo from '@/assets/hutopy-icon-white-circle.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const baseURL = window.location.origin;
|
||||
const client = useClient();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// Copy URL state
|
||||
const copySuccess = ref(false);
|
||||
const copyButtonRef = ref(null);
|
||||
|
||||
// Computed properties for Stripe status
|
||||
const stripeReady = computed(() => {
|
||||
return stripeStatus.value === 'fully_configured';
|
||||
});
|
||||
|
||||
const stripeStatus = computed(() => {
|
||||
console.log('stripeStatus');
|
||||
const creator = creatorProfileStore.creator;
|
||||
|
||||
if (!creator.isStripeAccountPresent) {
|
||||
return 'not_configured';
|
||||
}
|
||||
|
||||
if (!creator.isStripeDetailsSubmitted) {
|
||||
return 'needs_more_info';
|
||||
}
|
||||
|
||||
if (!creator.isStripeChargesEnabled || !creator.isStripePayoutReady) {
|
||||
return 'pending_verification';
|
||||
}
|
||||
|
||||
return 'fully_configured';
|
||||
});
|
||||
|
||||
const stripeStatusText = computed(() => {
|
||||
switch (stripeStatus.value) {
|
||||
case 'not_configured':
|
||||
return t('notConfigured');
|
||||
case 'needs_more_info':
|
||||
return t('needsMoreInfo');
|
||||
case 'pending_verification':
|
||||
return t('pendingVerification');
|
||||
default:
|
||||
return t('configured');
|
||||
}
|
||||
});
|
||||
|
||||
const stripeButtonText = computed(() => {
|
||||
switch (stripeStatus.value) {
|
||||
case 'not_configured':
|
||||
return t('configureStripe');
|
||||
case 'needs_more_info':
|
||||
case 'pending_verification':
|
||||
return t('continueStripeSetup');
|
||||
case 'fully_configured':
|
||||
return t('removeStripe');
|
||||
default:
|
||||
return t('removeStripe');
|
||||
}
|
||||
});
|
||||
|
||||
const imageSettings = ref({
|
||||
src: hutopyLogo,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
width: 64,
|
||||
height: 64,
|
||||
excavate: false,
|
||||
});
|
||||
|
||||
async function checkStripeAccountStatus() {
|
||||
try {
|
||||
const response = await client.post('/api/stripe/check-status');
|
||||
if (response.data && response.data.stripeAccount) {
|
||||
creatorProfileStore.creator.isStripeAccountPresent = response.data.isStripeAccountPresent;
|
||||
creatorProfileStore.creator.isStripeDetailsSubmitted = response.data.isStripeDetailsSubmitted;
|
||||
creatorProfileStore.creator.isStripePayoutReady = response.data.isStripePayoutReady;
|
||||
creatorProfileStore.creator.isStripeChargesEnabled = response.data.isStripeChargesEnabled;
|
||||
toast.success('Your Stripe account is connected and ready for payouts.');
|
||||
} else {
|
||||
toast.success('Your Stripe account is connected.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('We couldn’t verify your Stripe connection. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { stripe } = route.query;
|
||||
|
||||
if (stripe === 'complete') {
|
||||
await checkStripeAccountStatus();
|
||||
}
|
||||
|
||||
if (stripe === 'refresh') {
|
||||
toast.warning('You didn’t finish connecting your Stripe account. Please try again.');
|
||||
}
|
||||
|
||||
if (stripe) {
|
||||
await router.replace({ query: { ...route.query, stripe: undefined } });
|
||||
}
|
||||
});
|
||||
|
||||
async function copyCreatorUrl() {
|
||||
try {
|
||||
const url = `${baseURL}/@${creatorProfileStore.creator.slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
copySuccess.value = true;
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ### Fullname
|
||||
const dialogEditFullnameShown = ref(false);
|
||||
|
||||
function openEditFullname() {
|
||||
dialogEditFullnameShown.value = true;
|
||||
}
|
||||
|
||||
function handleCloseEditFullname() {
|
||||
dialogEditFullnameShown.value = false;
|
||||
}
|
||||
|
||||
function handleSaveEditFullname(firstname, lastname) {
|
||||
userProfileStore.changeFullname(firstname, lastname);
|
||||
dialogEditFullnameShown.value = false;
|
||||
}
|
||||
|
||||
// ### Alias
|
||||
const dialogEditAliasShown = ref(false);
|
||||
|
||||
function openEditAlias() {
|
||||
dialogEditAliasShown.value = true;
|
||||
}
|
||||
|
||||
function handleCloseEditAlias() {
|
||||
dialogEditAliasShown.value = false;
|
||||
}
|
||||
|
||||
function handleSaveEditAlias(alias) {
|
||||
userProfileStore.changeAlias(alias);
|
||||
dialogEditAliasShown.value = false;
|
||||
}
|
||||
|
||||
const dialogShown = ref(false);
|
||||
const currentComponent = ref('');
|
||||
const restoreDialogShown = ref(false);
|
||||
const deleteDialogShown = ref(false);
|
||||
|
||||
const componentsMap = {
|
||||
EmailDialog: markRaw(EmailDialog),
|
||||
ChangePasswordDialog: markRaw(ChangePasswordDialog),
|
||||
SocialsDialog: markRaw(SocialsDialog),
|
||||
ChangeSlugDialog: markRaw(ChangeSlugDialog),
|
||||
ChangeNameDialog: markRaw(ChangeNameDialog),
|
||||
ChangeTitleDialog: markRaw(ChangeTitleDialog),
|
||||
ChangeStripeIdDialog: markRaw(ChangeStripeIdDialog),
|
||||
ChangePhoneDialog: markRaw(ChangePhoneDialog),
|
||||
ChangeEmailDialog: markRaw(ChangeEmailDialog),
|
||||
};
|
||||
|
||||
const stripeButtonBusy = ref(false);
|
||||
|
||||
async function connectStripe() {
|
||||
try {
|
||||
stripeButtonBusy.value = true;
|
||||
const res = await client.post('/api/stripe/connect');
|
||||
window.location.href = res.data.url;
|
||||
} catch (error) {
|
||||
toast.error('We couldn’t connect your Stripe account. Please try again.');
|
||||
stripeButtonBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStripe() {
|
||||
try {
|
||||
stripeButtonBusy.value = true;
|
||||
await client.delete('/api/stripe');
|
||||
creatorProfileStore.creator.isStripeAccountPresent = false;
|
||||
creatorProfileStore.creator.isStripeDetailsSubmitted = false;
|
||||
creatorProfileStore.creator.isStripePayoutReady = false;
|
||||
creatorProfileStore.creator.isStripeChargesEnabled = false;
|
||||
} catch (error) {
|
||||
toast.error('We couldn’t connect your Stripe account. Please try again.');
|
||||
} finally {
|
||||
stripeButtonBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const openDialog = component => {
|
||||
currentComponent.value = componentsMap[component];
|
||||
dialogShown.value = true;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
currentComponent.value = null;
|
||||
dialogShown.value = false;
|
||||
};
|
||||
|
||||
function handleRestore() {
|
||||
creatorProfileStore.restoreCreatorPage();
|
||||
restoreDialogShown.value = false;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
creatorProfileStore.removeCreatorPage();
|
||||
deleteDialogShown.value = false;
|
||||
}
|
||||
|
||||
function downloadQRCode() {
|
||||
try {
|
||||
// Get the SVG element more specifically
|
||||
const qrContainer = document.querySelector('.qr-code');
|
||||
const canvasElement = qrContainer?.querySelector('canvas');
|
||||
|
||||
if (!canvasElement) {
|
||||
console.error('QR code canvas element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = 20;
|
||||
const newCanvas = document.createElement('canvas');
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
|
||||
// Set canvas size to include padding
|
||||
newCanvas.width = canvasElement.width + padding * 2;
|
||||
newCanvas.height = canvasElement.height + padding * 2;
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
|
||||
|
||||
// Draw the original QR code canvas with padding
|
||||
ctx.drawImage(canvasElement, padding, padding);
|
||||
|
||||
// Convert to PNG and download
|
||||
const pngUrl = newCanvas.toDataURL('image/png');
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = pngUrl;
|
||||
downloadLink.download = `hutopy-qr-${creatorProfileStore.creator.slug}.png`;
|
||||
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
} catch (error) {
|
||||
console.error('Error in downloadQRCode:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deconfigureStripe() {
|
||||
try {
|
||||
await client.post(`/api/membership/stripe-account`, {
|
||||
stripeAccountId: '',
|
||||
});
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
} catch (error) {
|
||||
console.error('Error deconfiguring stripe:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen w-full">
|
||||
<div class="m-4 flex flex-col items-center gap-4">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('personalInfo') }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button
|
||||
class="action"
|
||||
@click="openEditFullname"
|
||||
>
|
||||
<span class="label">{{ t('fullName') }}</span>
|
||||
<span class="value">{{ userProfileStore.fullname }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openEditAlias"
|
||||
>
|
||||
<span class="label">{{ t('alias') }}</span>
|
||||
<span class="value">{{ userProfileStore.user.alias }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('EmailDialog')"
|
||||
>
|
||||
<span class="label">{{ t('email') }}</span>
|
||||
<span class="value">{{ userProfileStore.user.email }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('ChangePasswordDialog')"
|
||||
>
|
||||
<span class="label">{{ t('changePassword') }}</span>
|
||||
<span class="value">••••••••</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="creatorProfileStore.hasCreator">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('creatorInfo') }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
class="action"
|
||||
@click="openDialog('ChangeSlugDialog')"
|
||||
>
|
||||
<span class="label">{{ t('handle') }}</span>
|
||||
<span class="value">{{ baseURL }}/@{{ creatorProfileStore.creator.slug }}</span>
|
||||
<button
|
||||
ref="copyButtonRef"
|
||||
:class="{ success: copySuccess }"
|
||||
class="copy-button"
|
||||
@click.stop="copyCreatorUrl"
|
||||
>
|
||||
<v-icon :icon="copySuccess ? mdiCheck : mdiContentCopy" />
|
||||
</button>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- NAME -->
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('ChangeNameDialog')"
|
||||
>
|
||||
<span class="label">{{ t('name') }}</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.name }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- TITLE -->
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('ChangeTitleDialog')"
|
||||
>
|
||||
<span class="label">{{ t('title') }}</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.title }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- PHONE NUMBER -->
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('ChangePhoneDialog')"
|
||||
>
|
||||
<span class="label">{{ t('phoneNumber') }}</span>
|
||||
<span
|
||||
:class="{ 'not-set': !creatorProfileStore.creator.presentation?.phoneNumber }"
|
||||
class="value"
|
||||
>
|
||||
{{ creatorProfileStore.creator.presentation?.phoneNumber || t('notSet') }}
|
||||
</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- EMAIL -->
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('ChangeEmailDialog')"
|
||||
>
|
||||
<span class="label">{{ t('email') }}</span>
|
||||
<span
|
||||
:class="{ 'not-set': !creatorProfileStore.creator.presentation?.email }"
|
||||
class="value"
|
||||
>
|
||||
{{ creatorProfileStore.creator.presentation?.email || t('notSet') }}
|
||||
</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('payment-information') }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="stripe-status">
|
||||
<span class="label">{{ t('stripeStatus') }}</span>
|
||||
<span
|
||||
:class="stripeStatus"
|
||||
class="value"
|
||||
>
|
||||
{{ stripeStatusText }}
|
||||
</span>
|
||||
<div class="stripe-actions">
|
||||
<button
|
||||
:class="stripeReady ? 'deconfigure-stripe-button' : 'configure-stripe-button'"
|
||||
:disabled="stripeButtonBusy"
|
||||
@click="() => (stripeReady ? removeStripe() : connectStripe())"
|
||||
>
|
||||
<v-icon
|
||||
v-if="!stripeButtonBusy"
|
||||
:icon="stripeReady ? mdiCreditCardOff : mdiCreditCard"
|
||||
/>
|
||||
<v-progress-circular
|
||||
v-else
|
||||
class="mr-2"
|
||||
color="text-hTextOnPrimary"
|
||||
indeterminate
|
||||
size="20"
|
||||
width="2"
|
||||
/>
|
||||
{{ stripeButtonText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('socialNetworks') }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<facebook class="social-icon"></facebook>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.facebookUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<instagram class="social-icon"></instagram>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.instagramUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<x class="social-icon"></x>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.xUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<linkedin class="social-icon"></linkedin>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.linkedInUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<tiktok class="social-icon"></tiktok>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.tikTokUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<youtube class="social-icon"></youtube>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.youtubeUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<reddit class="social-icon"></reddit>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.redditUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action"
|
||||
@click="openDialog('SocialsDialog')"
|
||||
>
|
||||
<span class="label">
|
||||
<web class="social-icon"></web>
|
||||
</span>
|
||||
<span class="value">{{ creatorProfileStore.creator.socials?.websiteUrl }}</span>
|
||||
<span class="chevron">
|
||||
<v-icon :icon="mdiChevronRight" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('qrCode') }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="qr-container">
|
||||
<p class="qr-text">{{ t('qrCodeDescription') }}</p>
|
||||
<div class="qr-code">
|
||||
<QRCodeVue
|
||||
v-if="creatorProfileStore.creator"
|
||||
:image-settings="imageSettings"
|
||||
:margin="0"
|
||||
:size="200"
|
||||
:value="baseURL + '/@' + creatorProfileStore.creator.slug"
|
||||
foreground="#6B0065"
|
||||
level="H"
|
||||
render-as="canvas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="creatorProfileStore.creator"
|
||||
class="download-button"
|
||||
@click="downloadQRCode"
|
||||
>
|
||||
<v-icon :icon="mdiDownload" />
|
||||
{{ t('downloadQRCode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
{{ t('dangerZone') }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="p-2">
|
||||
{{ t('dangerZoneWarning') }}
|
||||
</span>
|
||||
<div class="p-2 m-2 w-auto flex justify-center">
|
||||
<div class="w-1/3">
|
||||
<button
|
||||
v-if="!creatorProfileStore.creator.isDeleted"
|
||||
class="primary danger-action"
|
||||
@click="deleteDialogShown = true"
|
||||
>
|
||||
{{ t('deleteCreatorPage') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="primary safe-action"
|
||||
@click="restoreDialogShown = true"
|
||||
>
|
||||
{{ t('restoreCreatorPage') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-dialog
|
||||
v-model="dialogEditFullnameShown"
|
||||
persistent
|
||||
>
|
||||
<FullnameDialog
|
||||
:firstname="userProfileStore.user.firstname"
|
||||
:lastname="userProfileStore.user.lastname"
|
||||
@close="handleCloseEditFullname"
|
||||
@save="handleSaveEditFullname"
|
||||
/>
|
||||
</v-dialog>
|
||||
<v-dialog
|
||||
v-model="dialogEditAliasShown"
|
||||
persistent
|
||||
>
|
||||
<alias-dialog
|
||||
:alias="userProfileStore.user.alias"
|
||||
@close="handleCloseEditAlias"
|
||||
@save="handleSaveEditAlias"
|
||||
></alias-dialog>
|
||||
</v-dialog>
|
||||
<v-dialog
|
||||
v-model="dialogShown"
|
||||
persistent
|
||||
>
|
||||
<component
|
||||
:is="currentComponent"
|
||||
:creator="creatorProfileStore.creator"
|
||||
:email="userProfileStore.user.email"
|
||||
@closeRequested="closeDialog"
|
||||
></component>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="restoreDialogShown">
|
||||
<div class="card dialog">
|
||||
<div class="card-title">{{ t('restoreCreatorPage') }}</div>
|
||||
<div class="card-content">
|
||||
<p>{{ t('restoreWarning') }}</p>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="restoreDialogShown = false"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="handleRestore"
|
||||
>
|
||||
{{ t('accept') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="deleteDialogShown">
|
||||
<div class="card dialog">
|
||||
<div class="card-title">{{ t('deleteCreatorPage') }}</div>
|
||||
<div class="card-content">
|
||||
<p>{{ t('deleteWarning') }}</p>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="deleteDialogShown = false"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary danger-action"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ t('accept') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply rounded-lg p-4 w-full;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-hOnBackground text-lg font-bold;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.action {
|
||||
@apply flex flex-row items-center w-full p-3 rounded-lg;
|
||||
@apply hover:bg-hSurface;
|
||||
@apply transition-colors duration-500;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-hOnBackground w-[200px] text-left;
|
||||
@apply flex items-center justify-start;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
@apply ml-2 p-1 rounded-full;
|
||||
@apply transition-all duration-300;
|
||||
@apply relative overflow-hidden;
|
||||
@apply opacity-60;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
@apply opacity-100;
|
||||
@apply bg-hSurface;
|
||||
}
|
||||
|
||||
.copy-button::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-white/20;
|
||||
@apply scale-0 rounded-full;
|
||||
@apply transition-transform duration-300;
|
||||
}
|
||||
|
||||
.copy-button:active::after {
|
||||
@apply scale-150;
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.copy-button.success {
|
||||
@apply bg-green-500/20;
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.value {
|
||||
@apply text-hOnBackground flex-1 text-center;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.value.not-set {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
@apply text-hOnBackground w-[40px] text-right;
|
||||
@apply flex items-center justify-end;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
@apply fill-current w-6 h-6;
|
||||
@apply text-hOnBackground;
|
||||
@apply mr-2;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
@apply bg-red-800 hover:bg-red-700 active:bg-red-600;
|
||||
}
|
||||
|
||||
.safe-action {
|
||||
@apply bg-green-800 hover:bg-green-700 active:bg-green-600;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
@apply flex flex-col items-center gap-4 p-4;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
@apply bg-white p-4 rounded-2xl;
|
||||
}
|
||||
|
||||
.qr-text {
|
||||
@apply text-hOnBackground text-center;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
@apply flex items-center gap-2 px-4 py-2 rounded-lg;
|
||||
@apply bg-hutopyPrimary text-hOnPrimary;
|
||||
@apply hover:bg-hutopySecondary;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
.stripe-status {
|
||||
@apply flex flex-row items-center w-full p-3 rounded-lg;
|
||||
@apply bg-hSurface;
|
||||
@apply cursor-default;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
.stripe-status .value {
|
||||
@apply text-hOnBackground flex-1 text-center;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.stripe-status .value.fully_configured {
|
||||
@apply text-green-500;
|
||||
}
|
||||
|
||||
.stripe-status .value.configured {
|
||||
@apply text-green-500;
|
||||
}
|
||||
|
||||
.stripe-status .value.pending_verification {
|
||||
@apply text-yellow-500;
|
||||
}
|
||||
|
||||
.stripe-status .value.needs_more_info {
|
||||
@apply text-orange-500;
|
||||
}
|
||||
|
||||
.stripe-actions {
|
||||
@apply flex items-center gap-2 ml-4;
|
||||
}
|
||||
|
||||
.configure-stripe-button {
|
||||
@apply flex items-center justify-center gap-2 px-4 py-2 rounded-lg;
|
||||
@apply bg-hutopyPrimary text-hOnPrimary;
|
||||
@apply hover:bg-hutopySecondary;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
.deconfigure-stripe-button {
|
||||
@apply flex items-center justify-center gap-2 px-4 py-2 rounded-lg;
|
||||
@apply bg-red-600 text-white;
|
||||
@apply hover:bg-red-700;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"personalInfo": "Personal Information",
|
||||
"fullName": "Full Name",
|
||||
"alias": "Alias",
|
||||
"email": "Email",
|
||||
"changePassword": "Update Password",
|
||||
"creatorInfo": "Creator Information",
|
||||
"dangerZone": "Danger Zone",
|
||||
"dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.",
|
||||
"deleteWarning": "Are you sure you want to delete your creator page? This action cannot be undone.",
|
||||
"restoreWarning": "Are you sure you want to restore your creator page? This will make your page visible again.",
|
||||
"deleteCreatorPage": "Delete Creator Page",
|
||||
"restoreCreatorPage": "Restore Creator Page",
|
||||
"stripeAccountId": "Stripe Account ID",
|
||||
"socialNetworks": "Social Networks",
|
||||
"handle": "Creator Handle",
|
||||
"qrCode": "QR Code",
|
||||
"qrCodeDescription": "Print this QR code to share your Hutopy with the world! Perfect for business cards, social media, and promotional materials.",
|
||||
"downloadQRCode": "Download QR Code",
|
||||
"payment-information": "Payment Information",
|
||||
"stripeStatus": "Stripe Status",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not Configured",
|
||||
"needsMoreInfo": "Requires More Information",
|
||||
"pendingVerification": "Pending Verification",
|
||||
"continueStripeSetup": "Continue Stripe Setup",
|
||||
"reviewStripe": "Review Stripe",
|
||||
"notSet": "Not Set",
|
||||
"configureStripe": "Connect Stripe",
|
||||
"phoneNumber": "Phone Number",
|
||||
"title": "Title",
|
||||
"removeStripe": "Remove Stripe"
|
||||
},
|
||||
"fr": {
|
||||
"personalInfo": "Informations Personnelles",
|
||||
"fullName": "Nom Complet",
|
||||
"alias": "Alias",
|
||||
"email": "Email",
|
||||
"changePassword": "Modifier le mot de passe",
|
||||
"creatorInfo": "Informations du Créateur",
|
||||
"dangerZone": "Zone de Danger",
|
||||
"dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.",
|
||||
"deleteWarning": "Êtes-vous sûr de vouloir supprimer votre page de créateur ? Cette action est irréversible.",
|
||||
"restoreWarning": "Êtes-vous sûr de vouloir restaurer votre page de créateur ? Cela rendra votre page à nouveau visible.",
|
||||
"deleteCreatorPage": "Supprimer la Page Créateur",
|
||||
"restoreCreatorPage": "Restaurer la Page Créateur",
|
||||
"stripeAccountId": "ID de Compte Stripe",
|
||||
"socialNetworks": "Réseaux Sociaux",
|
||||
"handle": "Identifiant du créateur",
|
||||
"qrCode": "Code QR",
|
||||
"qrCodeDescription": "Imprimez ce code QR pour partager votre Hutopy avec le monde ! Parfait pour les cartes de visite, les réseaux sociaux et les supports promotionnels.",
|
||||
"downloadQRCode": "Télécharger le Code QR",
|
||||
"payment-information": "Informations de Paiement",
|
||||
"stripeStatus": "État de Stripe",
|
||||
"configured": "Configuré",
|
||||
"notConfigured": "Non Configuré",
|
||||
"needsMoreInfo": "Demande plus d'informations",
|
||||
"pendingVerification": "Vérification en Cours",
|
||||
"continueStripeSetup": "Continuer Configuration Stripe",
|
||||
"reviewStripe": "Reviser Stripe",
|
||||
"notSet": "Non Défini",
|
||||
"configureStripe": "Connecter Stripe",
|
||||
"phoneNumber": "Numéro de téléphone",
|
||||
"title": "Titre",
|
||||
"removeStripe": "Retirer Stripe"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="alias"
|
||||
:label="t('label')"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="primary"
|
||||
@click="requestSave"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps(['alias']);
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const alias = ref(props.alias);
|
||||
|
||||
const requestClose = () => emit('close');
|
||||
const requestSave = () => emit('save', alias.value);
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Alias",
|
||||
"label": "Your alias"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Alias",
|
||||
"label": "Votre alias"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('changePassword') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="description mb-4">{{ t('passwordDescription') }}</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="newPassword"
|
||||
:hint="t('passwordRequirements')"
|
||||
:label="t('newPassword')"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
required
|
||||
variant="outlined"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-icon
|
||||
:icon="showNewPassword ? mdiEyeOff : mdiEye"
|
||||
class="visibility-toggle"
|
||||
size="small"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="confirmPassword"
|
||||
:label="t('confirmPassword')"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
required
|
||||
variant="outlined"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-icon
|
||||
:icon="showNewPassword ? mdiEyeOff : mdiEye"
|
||||
class="visibility-toggle"
|
||||
size="small"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="error-message mb-4"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="$emit('closeRequested')"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
class="primary"
|
||||
@click="handleChangePassword"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const emit = defineEmits(['closeRequested']);
|
||||
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const showNewPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
|
||||
async function handleChangePassword() {
|
||||
// Clear previous error
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate passwords match
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('passwordsDoNotMatch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (newPassword.value.length < 8) {
|
||||
errorMessage.value = t('passwordTooShort');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Pass empty string for current password since we're already authenticated
|
||||
// This will use the set-password endpoint for OAuth users
|
||||
await authStore.changePassword(newPassword.value);
|
||||
|
||||
// Success - close dialog
|
||||
emit('closeRequested');
|
||||
|
||||
// You could also emit a success event if needed
|
||||
// emit('success');
|
||||
} catch (error) {
|
||||
console.error('Failed to change password:', error);
|
||||
// Use error message from response if available, or the error message itself, or fallback
|
||||
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog {
|
||||
@apply max-w-md mx-auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-red-500 text-sm mt-2;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
@apply cursor-pointer;
|
||||
@apply transition-opacity duration-300;
|
||||
@apply opacity-60 hover:opacity-100;
|
||||
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
/* Override Vuetify's default padding to accommodate our icon */
|
||||
:deep(.v-field__append-inner) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"changePassword": "Update Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm New Password",
|
||||
"passwordRequirements": "Password must be at least 8 characters",
|
||||
"passwordDescription": "Updating your password allows you to log in directly with your email and password.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"passwordsDoNotMatch": "New passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 8 characters long",
|
||||
"passwordUpdateFailed": "Failed to update password. Please try again."
|
||||
},
|
||||
"fr": {
|
||||
"changePassword": "Modifier le mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le nouveau mot de passe",
|
||||
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"passwordDescription": "La modification de votre mot de passe vous permet de vous connecter directement avec votre email et mot de passe.",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
|
||||
</i18n>
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:label="t('label')"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
email: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const email = ref(props.email);
|
||||
|
||||
const client = useClient();
|
||||
const save = async () => {
|
||||
try {
|
||||
await client.post(`/api/users/email`, {
|
||||
email: email.value,
|
||||
});
|
||||
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emits('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Change your Email",
|
||||
"label": "Your email"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Changez votre Courriel",
|
||||
"label": "Votre email"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps(['firstname', 'lastname']);
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const firstname = ref(props.firstname);
|
||||
const lastname = ref(props.lastname);
|
||||
|
||||
const requestClose = () => emit('close');
|
||||
const requestSave = () => emit('save', firstname.value, lastname.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="firstname"
|
||||
:label="t('firstname')"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="lastname"
|
||||
:label="t('lastname')"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="primary"
|
||||
@click="requestSave"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Full Name",
|
||||
"firstname": "First Name",
|
||||
"lastname": "Last Name"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Nom complet",
|
||||
"firstname": "Prénom",
|
||||
"lastname": "Nom"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">{{ t('changeEmail') }}</div>
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:error-messages="emailErrors"
|
||||
:label="t('email')"
|
||||
:rules="emailRules"
|
||||
class="w-full p-2"
|
||||
type="email"
|
||||
validate-on="blur"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="!!errorMessage"
|
||||
class="mt-4"
|
||||
outlined
|
||||
type="error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="$emit('closeRequested')"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canSave || isLoading"
|
||||
class="primary"
|
||||
@click="saveEmail"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const client = useClient();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const email = ref(props.creator.presentation?.email || '');
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
// Email validation
|
||||
const isValidEmail = email => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const emailRules = [
|
||||
v => !!v || t('validation.emailRequired'),
|
||||
v => isValidEmail(v) || t('validation.emailInvalid'),
|
||||
];
|
||||
|
||||
const emailErrors = computed(() => {
|
||||
if (!email.value) {
|
||||
return [t('validation.emailRequired')];
|
||||
}
|
||||
if (!isValidEmail(email.value)) {
|
||||
return [t('validation.emailInvalid')];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
return email.value && isValidEmail(email.value) && email.value !== (props.creator.presentation?.email || '');
|
||||
});
|
||||
|
||||
async function saveEmail() {
|
||||
if (!props.creator.id) {
|
||||
console.error('Creator ID is missing!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSave.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
// Save email
|
||||
await client.post(`/api/creators/${props.creator.id}/email`, {
|
||||
email: email.value.trim(),
|
||||
});
|
||||
|
||||
// Refresh creator profile
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
|
||||
// Close dialog
|
||||
emit('closeRequested');
|
||||
} catch (error) {
|
||||
console.error('Error saving email:', error);
|
||||
if (error?.response?.data?.errors) {
|
||||
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
|
||||
} else {
|
||||
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['closeRequested']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog {
|
||||
@apply max-w-md mx-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"changeEmail": "Change Email",
|
||||
"email": "Email",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
"emailInvalid": "Please enter a valid email address"
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "An unexpected error occurred"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"changeEmail": "Modifier l'email",
|
||||
"email": "Email",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"validation": {
|
||||
"emailRequired": "L'email est requis",
|
||||
"emailInvalid": "Veuillez entrer une adresse email valide"
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "Une erreur inattendue s'est produite"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const name = ref(props.creator.name);
|
||||
|
||||
const client = useClient();
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await client.post(`/api/creators/${props.creator.id}/name`, {
|
||||
name: name.value,
|
||||
});
|
||||
|
||||
props.creator.name = name.value;
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error('Error saving title:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emits('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
:label="t('label')"
|
||||
outlined
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Change Name",
|
||||
"label": "Your name"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Modifier le nom",
|
||||
"label": "Votre nom"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">{{ t('changePhoneNumber') }}</div>
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="displayPhoneNumber"
|
||||
:error-messages="phoneErrors"
|
||||
:label="t('phoneNumber')"
|
||||
:placeholder="t('phonePlaceholder')"
|
||||
:rules="phoneRules"
|
||||
class="w-full p-2"
|
||||
maxlength="14"
|
||||
type="tel"
|
||||
validate-on="blur"
|
||||
variant="outlined"
|
||||
@input="handlePhoneInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="!!errorMessage"
|
||||
class="mt-4"
|
||||
outlined
|
||||
type="error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="$emit('closeRequested')"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canSave || isLoading"
|
||||
class="primary"
|
||||
@click="savePhoneNumber"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const client = useClient();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Format existing phone number to display format
|
||||
const formatPhoneForDisplay = phone => {
|
||||
if (!phone) return '';
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
if (digits.length === 10) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
// Extract just the digits from formatted phone
|
||||
const extractDigits = formattedPhone => {
|
||||
return formattedPhone.replace(/\D/g, '');
|
||||
};
|
||||
|
||||
const displayPhoneNumber = ref(formatPhoneForDisplay(props.creator.presentation?.phoneNumber || ''));
|
||||
const phoneDigits = ref(extractDigits(displayPhoneNumber.value));
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
// Phone number formatting and validation
|
||||
const formatPhoneNumber = digits => {
|
||||
// Remove all non-digits
|
||||
const cleaned = digits.replace(/\D/g, '');
|
||||
|
||||
// Apply formatting based on length
|
||||
if (cleaned.length === 0) return '';
|
||||
if (cleaned.length <= 3) return `(${cleaned}`;
|
||||
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
|
||||
};
|
||||
|
||||
const handlePhoneInput = event => {
|
||||
const input = event.target.value;
|
||||
const digits = extractDigits(input);
|
||||
|
||||
// Limit to 10 digits
|
||||
if (digits.length > 10) return;
|
||||
|
||||
phoneDigits.value = digits;
|
||||
displayPhoneNumber.value = formatPhoneNumber(digits);
|
||||
};
|
||||
|
||||
const handleKeydown = event => {
|
||||
// Allow backspace, delete, tab, escape, enter
|
||||
if ([8, 9, 27, 13, 46].includes(event.keyCode)) return;
|
||||
|
||||
// Allow Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
|
||||
if ((event.ctrlKey || event.metaKey) && [65, 67, 86, 88].includes(event.keyCode)) return;
|
||||
|
||||
// Allow arrow keys
|
||||
if (event.keyCode >= 35 && event.keyCode <= 40) return;
|
||||
|
||||
// Only allow numbers (0-9)
|
||||
if (event.keyCode < 48 || event.keyCode > 57) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes to phoneDigits to update display
|
||||
watch(phoneDigits, newDigits => {
|
||||
displayPhoneNumber.value = formatPhoneNumber(newDigits);
|
||||
});
|
||||
|
||||
const isValidPhoneNumber = digits => {
|
||||
return digits.length === 10;
|
||||
};
|
||||
|
||||
const phoneRules = [
|
||||
v => {
|
||||
const digits = extractDigits(v);
|
||||
return digits.length > 0 || t('validation.phoneRequired');
|
||||
},
|
||||
v => {
|
||||
const digits = extractDigits(v);
|
||||
return isValidPhoneNumber(digits) || t('validation.phoneInvalid');
|
||||
},
|
||||
];
|
||||
|
||||
const phoneErrors = computed(() => {
|
||||
if (phoneDigits.value.length === 0) {
|
||||
return [t('validation.phoneRequired')];
|
||||
}
|
||||
if (!isValidPhoneNumber(phoneDigits.value)) {
|
||||
return [t('validation.phoneInvalid')];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
return (
|
||||
phoneDigits.value.length === 10 &&
|
||||
phoneDigits.value !== extractDigits(props.creator.presentation?.phoneNumber || '')
|
||||
);
|
||||
});
|
||||
|
||||
async function savePhoneNumber() {
|
||||
if (!props.creator.id) {
|
||||
console.error('Creator ID is missing!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSave.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
// Save the formatted phone number
|
||||
const formattedPhone = formatPhoneNumber(phoneDigits.value);
|
||||
|
||||
await client.post(`/api/creators/${props.creator.id}/phone`, {
|
||||
phoneNumber: formattedPhone,
|
||||
});
|
||||
|
||||
// Refresh creator profile
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
|
||||
// Close dialog
|
||||
emit('closeRequested');
|
||||
} catch (error) {
|
||||
console.error('Error saving phone number:', error);
|
||||
if (error?.response?.data?.errors) {
|
||||
errorMessage.value = error.response.data.errors[0]?.['reason'] || t('errors.unexpected');
|
||||
} else {
|
||||
errorMessage.value = error?.response?.data?.message || error.message || t('errors.unexpected');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['closeRequested']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog {
|
||||
@apply max-w-md mx-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"changePhoneNumber": "Change Phone Number",
|
||||
"phoneNumber": "Phone Number",
|
||||
"phonePlaceholder": "(555) 123-4567",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"validation": {
|
||||
"phoneRequired": "Phone number is required",
|
||||
"phoneInvalid": "Please enter a complete 10-digit phone number"
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "An unexpected error occurred"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"changePhoneNumber": "Modifier le numéro de téléphone",
|
||||
"phoneNumber": "Numéro de téléphone",
|
||||
"phonePlaceholder": "(555) 123-4567",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"validation": {
|
||||
"phoneRequired": "Le numéro de téléphone est requis",
|
||||
"phoneInvalid": "Veuillez entrer un numéro de téléphone complet à 10 chiffres"
|
||||
},
|
||||
"errors": {
|
||||
"unexpected": "Une erreur inattendue s'est produite"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import NameEditor from '@/views/creators/NameEditor.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['closeRequested']);
|
||||
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const client = useClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
const newSlug = ref(props.creator.slug);
|
||||
const slugReservationId = ref(undefined);
|
||||
const isOperationPending = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const isCurrentHandle = ref(false);
|
||||
|
||||
// Watch for changes to the new slug to check if it's the same as the current one
|
||||
watch(newSlug, newValue => {
|
||||
isCurrentHandle.value = newValue === props.creator.slug;
|
||||
if (isCurrentHandle.value) {
|
||||
slugReservationId.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => slugReservationId.value !== undefined && !isCurrentHandle.value);
|
||||
|
||||
function handleSlugReservationIdChanged($event) {
|
||||
slugReservationId.value = $event;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
isOperationPending.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
await client.put(`/api/creators/${props.creator.id}/slug`, {
|
||||
slugReservationId: slugReservationId.value,
|
||||
});
|
||||
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
emit('closeRequested');
|
||||
} catch (error) {
|
||||
if (error?.response?.data?.errors) {
|
||||
errorMessage.value = error.response.data.errors[0]?.['reason'] || 'An unexpected error occurred.';
|
||||
} else {
|
||||
errorMessage.value = error?.response?.data?.message || error.message || 'An unexpected error occurred.';
|
||||
}
|
||||
} finally {
|
||||
isOperationPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emit('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<name-editor
|
||||
v-model:name="newSlug"
|
||||
:creator-name-reservation-id="slugReservationId"
|
||||
:original-slug="creator.slug"
|
||||
@update:creator-name-reservation-id="handleSlugReservationIdChanged"
|
||||
></name-editor>
|
||||
|
||||
<v-alert
|
||||
v-if="!!errorMessage"
|
||||
class="mt-4"
|
||||
outlined
|
||||
type="error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="!canSave || isOperationPending"
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Change Creator Handle"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Modifier l'identifiant du créateur"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup>
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const stripeId = ref('');
|
||||
const { t } = useI18n();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
|
||||
const client = useClient();
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await client.post(`/api/membership/stripe-account`, {
|
||||
stripeAccountId: stripeId.value,
|
||||
});
|
||||
|
||||
await creatorProfileStore.fetchCreatorProfile();
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error('Error saving stripe id:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emits('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="stripeId"
|
||||
:label="t('label')"
|
||||
outlined
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Change Stripe ID",
|
||||
"label": "Your Stripe ID"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Modifier l'ID Stripe",
|
||||
"label": "Votre ID Stripe"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const title = ref(props.creator.title);
|
||||
const { t } = useI18n();
|
||||
|
||||
const client = useClient();
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await client.post(`/api/creators/${props.creator.id}/title`, {
|
||||
title: title.value,
|
||||
});
|
||||
|
||||
props.creator.title = title.value;
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error('Error saving title:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
emits('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<v-text-field
|
||||
v-model="title"
|
||||
:label="t('label')"
|
||||
outlined
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Change Title",
|
||||
"label": "Your title"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Modifier le titre",
|
||||
"label": "Votre titre"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,200 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import X from '@/views/svg/X.vue';
|
||||
import Tiktok from '@/views/svg/Tiktok.vue';
|
||||
import Reddit from '@/views/svg/Reddit.vue';
|
||||
import Web from '@/views/svg/Web.vue';
|
||||
import Youtube from '@/views/svg/Youtube.vue';
|
||||
import Linkedin from '@/views/svg/Linkedin.vue';
|
||||
import Instagram from '@/views/svg/Instagram.vue';
|
||||
import Facebook from '@/views/svg/Facebook.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
creator: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['closeRequested']);
|
||||
|
||||
const facebookUrl = ref(props.creator.socials.facebookUrl);
|
||||
const instagramUrl = ref(props.creator.socials.instagramUrl);
|
||||
const linkedInUrl = ref(props.creator.socials.linkedInUrl);
|
||||
const redditUrl = ref(props.creator.socials.redditUrl);
|
||||
const tikTokUrl = ref(props.creator.socials.tikTokUrl);
|
||||
const websiteUrl = ref(props.creator.socials.websiteUrl);
|
||||
const xUrl = ref(props.creator.socials.xUrl);
|
||||
const youtubeUrl = ref(props.creator.socials.youtubeUrl);
|
||||
|
||||
const client = useClient();
|
||||
const save = async () => {
|
||||
try {
|
||||
await client.post(`/api/creators/${props.creator.id}/socials`, {
|
||||
facebookUrl: facebookUrl.value || null,
|
||||
instagramUrl: instagramUrl.value || null,
|
||||
linkedInUrl: linkedInUrl.value || null,
|
||||
redditUrl: redditUrl.value || null,
|
||||
tikTokUrl: tikTokUrl.value || null,
|
||||
websiteUrl: websiteUrl.value || null,
|
||||
xUrl: xUrl.value || null,
|
||||
youtubeUrl: youtubeUrl.value || null,
|
||||
});
|
||||
|
||||
props.creator.socials.facebookUrl = facebookUrl;
|
||||
props.creator.socials.instagramUrl = instagramUrl;
|
||||
props.creator.socials.linkedInUrl = linkedInUrl;
|
||||
props.creator.socials.redditUrl = redditUrl;
|
||||
props.creator.socials.tikTokUrl = tikTokUrl;
|
||||
props.creator.socials.websiteUrl = websiteUrl;
|
||||
props.creator.socials.xUrl = xUrl;
|
||||
props.creator.socials.youtubeUrl = youtubeUrl;
|
||||
|
||||
emits('closeRequested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emits('closeRequested');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card dialog">
|
||||
<div class="card-title">
|
||||
{{ t('title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="editor-line">
|
||||
<facebook class="social-icon"></facebook>
|
||||
<input
|
||||
v-model="facebookUrl"
|
||||
:placeholder="t('facebook')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<instagram class="social-icon"></instagram>
|
||||
<input
|
||||
v-model="instagramUrl"
|
||||
:placeholder="t('instagram')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<linkedin class="social-icon"></linkedin>
|
||||
<input
|
||||
v-model="linkedInUrl"
|
||||
:placeholder="t('linkedin')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<reddit class="social-icon"></reddit>
|
||||
<input
|
||||
v-model="redditUrl"
|
||||
:placeholder="t('reddit')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<tiktok class="social-icon"></tiktok>
|
||||
<input
|
||||
v-model="tikTokUrl"
|
||||
:placeholder="t('tiktok')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<web class="social-icon"></web>
|
||||
<input
|
||||
v-model="websiteUrl"
|
||||
:placeholder="t('website')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<x class="social-icon"></x>
|
||||
<input
|
||||
v-model="xUrl"
|
||||
:placeholder="t('x')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="editor-line">
|
||||
<youtube class="social-icon"></youtube>
|
||||
<input
|
||||
v-model="youtubeUrl"
|
||||
:placeholder="t('youtube')"
|
||||
class="input-field"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-line {
|
||||
@apply flex flex-row gap-4;
|
||||
@apply items-center;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full p-[10px];
|
||||
@apply rounded-sm;
|
||||
@apply transition duration-200;
|
||||
@apply ring-1 ring-[#6D6C70] focus:outline-none focus:ring-hutopySecondary;
|
||||
@apply hover:ring-hutopyPrimary;
|
||||
@apply placeholder:text-[#6D6C70];
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"title": "Social Media Links"
|
||||
},
|
||||
"fr": {
|
||||
"title": "Liens des réseaux sociaux"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -1,16 +1,12 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import fs from 'fs';
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load environment variables based on the mode
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
return {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
visualizer({
|
||||
filename: './dist/stats.html',
|
||||
@@ -24,10 +20,6 @@ export default defineConfig(({ mode }) => {
|
||||
})
|
||||
],
|
||||
server: {
|
||||
https: {
|
||||
key: fs.readFileSync('localhost-key.pem'),
|
||||
cert: fs.readFileSync('localhost.pem'),
|
||||
},
|
||||
port: 5173, // Ensure this matches your WebStorm debug URL
|
||||
open: true, // Automatically opens the browser
|
||||
host: '0.0.0.0',
|
||||
@@ -54,13 +46,7 @@ export default defineConfig(({ mode }) => {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
define: {
|
||||
// Define a global constant __APP_ENV__ based on loaded environment variables
|
||||
VITE_API_URL: JSON.stringify(env.VITE_API_URL),
|
||||
VITE_STRIPE_API_KEY: JSON.stringify(env.VITE_STRIPE_API_KEY)
|
||||
},
|
||||
json: {
|
||||
stringify: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user