Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a511e1b30c | ||
|
|
d7fe497df6 | ||
|
|
5b3a4f7462 | ||
|
|
97e267040b | ||
|
|
3400a0fdf8 | ||
|
|
759a3ae0f1 | ||
|
|
6e9327fee4 | ||
|
|
5b5fb9d9c3 | ||
|
|
0c9d10c0b0 | ||
|
|
8035963b75 | ||
|
|
c027f6fc34 | ||
|
|
f390589e1a | ||
|
|
02e198d722 | ||
|
|
fa2b9df45b | ||
|
|
e95d33f172 | ||
|
|
91c3cd42f6 | ||
|
|
9fafc4e6f4 |
57
.dockerignore
Normal file
57
.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker files (não precisam estar dentro da imagem)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Arquivos de lock (npm/yarn/pnpm)
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
# Mantém pnpm-lock.yaml pois é necessário para build reproduzível
|
||||||
|
|
||||||
|
# Documentação
|
||||||
|
*.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Environment files (usar apenas .env.example como referência)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Ambiente (development ou production)
|
||||||
|
VITE_ENV=production
|
||||||
|
|
||||||
|
# URL da API do back-end
|
||||||
|
# No Dokploy com Traefik, usar o nome do serviço interno
|
||||||
|
VITE_API_URL=http://clipperia-api:3000
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
ARG BASE=/
|
||||||
|
ARG VITE_API_URL=https://api.clipperia.com.br
|
||||||
|
ENV VITE_BASE_PATH=$BASE
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
RUN pnpm build
|
||||||
|
RUN rm -rf node_modules
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
RUN mkdir -p /var/cache/nginx /var/run/nginx
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BASE: ${BASE:-/}
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-https://api.clipperia.com.br}
|
||||||
|
no_cache: true
|
||||||
|
image: ${IMAGE_NAME:-clipperia-web:latest}
|
||||||
|
container_name: clipperia
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
# Labels do Traefik gerenciados pelo Dokploy
|
||||||
|
# Se necessário configurar manualmente:
|
||||||
|
# labels:
|
||||||
|
# - traefik.enable=true
|
||||||
|
# - traefik.http.routers.clipperia.rule=Host(`clipperia.com.br`)
|
||||||
|
# - traefik.http.routers.clipperia.entrypoints=websecure
|
||||||
|
# - traefik.http.routers.clipperia.tls.certresolver=letsencrypt
|
||||||
|
# - traefik.http.services.clipperia.loadbalancer.server.port=80
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta description="This project is a front-end base project with Vue app" />
|
<meta description="This project is a front-end base project with Vue app" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<title>Vue Base JS</title>
|
<title>Clipper AI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
42
nginx.conf
Normal file
42
nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
absolute_redirect off;
|
||||||
|
port_in_redirect off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Configurações para proxy reverso (Traefik)
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
set_real_ip_from 0.0.0.0/0;
|
||||||
|
|
||||||
|
# Cabeçalhos de segurança
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# Cache de assets estáticos
|
||||||
|
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing - todas as rotas vão para index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint (opcional)
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "OK";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
831
src/App.vue
831
src/App.vue
@@ -1,89 +1,866 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-layout view="lHh lpr lff">
|
<q-layout view="lHh Lpr lFf" class="app-shell">
|
||||||
<q-header v-if="!isLoginPage" reveal class="bg-primary text-white">
|
<q-header v-if="!isLoginPage" class="app-header">
|
||||||
<q-toolbar>
|
<q-toolbar class="app-toolbar">
|
||||||
<Button dense flat round icon="mdi-menu" @click="toggleLeftDrawer" />
|
<div class="app-toolbar__page">
|
||||||
<q-toolbar-title> {{ currentRouteTitle }} </q-toolbar-title>
|
<q-btn
|
||||||
<Toggle v-model="darkMode" @toggle="toggleDarkMode" />
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="sym_o_menu"
|
||||||
|
class="app-toolbar__menu"
|
||||||
|
aria-label="Abrir menu"
|
||||||
|
@click="toggleLeftDrawer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="app-toolbar__breadcrumbs">
|
||||||
|
<span class="app-toolbar__eyebrow">Clipper · Painel</span>
|
||||||
|
<h1 class="app-toolbar__title">{{ currentRouteTitle }}</h1>
|
||||||
|
<p v-if="currentRouteDescription" class="app-toolbar__subtitle">
|
||||||
|
{{ currentRouteDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-toolbar__actions">
|
||||||
|
<Toggle v-model="darkMode" color="primary" @toggle="toggleDarkMode" />
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="!isLoginPage"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
class="app-toolbar__avatar-btn"
|
||||||
|
>
|
||||||
|
<q-avatar size="36px" class="app-toolbar__avatar">
|
||||||
|
<span>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<q-menu
|
||||||
|
class="app-user-menu"
|
||||||
|
anchor="bottom right"
|
||||||
|
self="top right"
|
||||||
|
>
|
||||||
|
<div class="app-user-menu__header">
|
||||||
|
<q-avatar size="40px" color="primary" text-color="white">
|
||||||
|
{{ userInitials }}
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<div class="app-user-menu__info">
|
||||||
|
<div class="app-user-menu__name">
|
||||||
|
{{ currentUserName || "Usuário" }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
currentUserEmail && currentUserEmail !== currentUserName
|
||||||
|
"
|
||||||
|
class="app-user-menu__email"
|
||||||
|
>
|
||||||
|
{{ currentUserEmail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator spaced />
|
||||||
|
|
||||||
|
<q-list padding class="app-user-menu__list">
|
||||||
|
<q-item clickable disable>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="sym_o_settings" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Configurações</q-item-label>
|
||||||
|
<q-item-label caption>Em breve</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
:disable="logoutLoading"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="sym_o_logout" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Logout</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
<q-drawer
|
<q-drawer
|
||||||
v-if="!isLoginPage"
|
v-if="!isLoginPage"
|
||||||
show-if-above
|
|
||||||
v-model="leftDrawerOpen"
|
v-model="leftDrawerOpen"
|
||||||
side="left"
|
show-if-above
|
||||||
|
:width="260"
|
||||||
bordered
|
bordered
|
||||||
|
class="app-drawer"
|
||||||
>
|
>
|
||||||
<div class="row q-pa-md items-center">
|
<div class="app-drawer__header">
|
||||||
<div class="col q-ml-xs title">
|
<q-avatar size="44px" color="primary" text-color="white">CI</q-avatar>
|
||||||
<span>Clipper AI</span>
|
<div>
|
||||||
|
<div class="app-drawer__brand">Clipper IA</div>
|
||||||
|
<div class="app-drawer__tag">Assistente de cortes</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator />
|
<q-scroll-area class="fit">
|
||||||
|
<template v-if="menuSections.length">
|
||||||
|
<q-list padding class="app-nav">
|
||||||
|
<template
|
||||||
|
v-for="(section, sectionIndex) in menuSections"
|
||||||
|
:key="section.key"
|
||||||
|
>
|
||||||
|
<q-item-label header class="app-nav__header">
|
||||||
|
<q-icon
|
||||||
|
v-if="section.icon"
|
||||||
|
:name="section.icon"
|
||||||
|
size="18px"
|
||||||
|
class="app-nav__header-icon"
|
||||||
|
/>
|
||||||
|
<span>{{ section.label }}</span>
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
<q-list>
|
|
||||||
<q-item
|
<q-item
|
||||||
v-for="route in routes.filter((route) => route.meta.showinModal)"
|
v-for="route in section.items"
|
||||||
|
:key="route.path"
|
||||||
clickable
|
clickable
|
||||||
v-ripple
|
v-ripple
|
||||||
|
:active="$route.path.startsWith(route.path)"
|
||||||
:to="route.path"
|
:to="route.path"
|
||||||
|
class="app-nav__item"
|
||||||
|
active-class="app-nav__item--active"
|
||||||
>
|
>
|
||||||
<q-item-section>{{ route.meta.title }}</q-item-section>
|
<q-item-section avatar>
|
||||||
|
<q-icon
|
||||||
|
:name="
|
||||||
|
route.meta?.icon || section.icon || 'sym_o_dashboard'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="app-nav__label">{{ route.meta?.title }}</div>
|
||||||
|
<div v-if="route.meta?.description" class="app-nav__caption">
|
||||||
|
{{ route.meta.description }}
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon
|
||||||
|
name="sym_o_chevron_right"
|
||||||
|
size="18px"
|
||||||
|
class="app-nav__chevron"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator
|
||||||
|
v-if="sectionIndex !== menuSections.length - 1"
|
||||||
|
spaced
|
||||||
|
inset
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</q-list>
|
</q-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="app-nav__empty">
|
||||||
|
<q-icon name="sym_o_lock_person" size="36px" color="primary" />
|
||||||
|
<div class="app-nav__empty-title">Nenhum menu disponível</div>
|
||||||
|
<p class="app-nav__empty-text">
|
||||||
|
Entre em contato com o administrador para habilitar as permissões
|
||||||
|
necessárias.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</q-scroll-area>
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
|
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
|
<div v-if="isLoginPage">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
|
<div v-else class="app-page">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
</q-layout>
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dark } from "quasar";
|
import { Dark } from "quasar";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import Toggle from "@components/Toggle";
|
import Toggle from "@components/Toggle";
|
||||||
|
|
||||||
import { routes } from "./auth/router";
|
import { API } from "@config/axios";
|
||||||
|
import { routes, MENUS } from "./auth/router";
|
||||||
|
import {
|
||||||
|
buildUserProfileFromToken,
|
||||||
|
extractUserInitials,
|
||||||
|
mapRolesToAppRoles,
|
||||||
|
} from "@/utils/keycloak";
|
||||||
|
|
||||||
|
const MENU_CONFIG = {
|
||||||
|
default: {
|
||||||
|
label: "Navegação",
|
||||||
|
icon: "sym_o_dashboard",
|
||||||
|
},
|
||||||
|
[MENUS.VIDEOS]: {
|
||||||
|
label: "Vídeos",
|
||||||
|
icon: "sym_o_video_library",
|
||||||
|
},
|
||||||
|
[MENUS.USUARIOS]: {
|
||||||
|
label: "Usuários",
|
||||||
|
icon: "sym_o_groups",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
Button,
|
Button,
|
||||||
Toggle,
|
Toggle,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
leftDrawerOpen: false,
|
leftDrawerOpen: true,
|
||||||
darkMode: Dark.isActive,
|
darkMode: Dark.isActive,
|
||||||
|
logoutLoading: false,
|
||||||
routes,
|
routes,
|
||||||
|
userProfile: this.getInitialUserProfile(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
navigationRoutes() {
|
||||||
|
return this.routes.filter(
|
||||||
|
(route) => route.meta?.showinModal && this.hasPermission(route)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
menuSections() {
|
||||||
|
const groups = this.navigationRoutes.reduce((acc, route) => {
|
||||||
|
const key = route.meta?.menu || "default";
|
||||||
|
|
||||||
|
if (!acc[key]) {
|
||||||
|
const config = MENU_CONFIG[key] || MENU_CONFIG.default || {};
|
||||||
|
|
||||||
|
acc[key] = {
|
||||||
|
key,
|
||||||
|
label:
|
||||||
|
config.label ||
|
||||||
|
route.meta?.menuLabel ||
|
||||||
|
route.meta?.title ||
|
||||||
|
"Navegação",
|
||||||
|
icon: config.icon,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[key].items.push(route);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.values(groups).map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: section.items.sort((a, b) => {
|
||||||
|
const orderA = a.meta?.order ?? 0;
|
||||||
|
const orderB = b.meta?.order ?? 0;
|
||||||
|
return orderA - orderB;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
currentRouteTitle() {
|
||||||
|
const route = this.$route;
|
||||||
|
return route.meta?.title || route.name || "Clipper IA";
|
||||||
|
},
|
||||||
|
currentRouteDescription() {
|
||||||
|
const current = this.$route.meta?.description;
|
||||||
|
if (current) return current;
|
||||||
|
|
||||||
|
const fallback = this.findParentRoute();
|
||||||
|
|
||||||
|
return (
|
||||||
|
fallback?.meta?.description || "Operacionalize cortes com inteligência."
|
||||||
|
);
|
||||||
|
},
|
||||||
|
quickAction() {
|
||||||
|
if (this.$route.meta?.quickAction === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$route.meta?.quickAction) {
|
||||||
|
return this.$route.meta.quickAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = this.findParentRoute();
|
||||||
|
|
||||||
|
return fallback?.meta?.quickAction || null;
|
||||||
|
},
|
||||||
|
isLoginPage() {
|
||||||
|
return this.$route.path === "/login";
|
||||||
|
},
|
||||||
|
userRoles() {
|
||||||
|
const profileRoles = Array.isArray(this.userProfile?.roles)
|
||||||
|
? this.userProfile.roles
|
||||||
|
: [];
|
||||||
|
const rawRoles = Array.isArray(this.userProfile?.rawRoles)
|
||||||
|
? this.userProfile.rawRoles
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const canonical = mapRolesToAppRoles([...profileRoles, ...rawRoles]);
|
||||||
|
|
||||||
|
return Array.from(new Set(canonical));
|
||||||
|
},
|
||||||
|
currentUserName() {
|
||||||
|
return this.userProfile?.name || this.userProfile?.username || "";
|
||||||
|
},
|
||||||
|
currentUserEmail() {
|
||||||
|
return this.userProfile?.email || this.userProfile?.username || "";
|
||||||
|
},
|
||||||
|
userInitials() {
|
||||||
|
return extractUserInitials(this.currentUserName) || "CI";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"$route.path"() {
|
||||||
|
this.syncDrawerWithViewport();
|
||||||
|
this.refreshUserProfile();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.syncDrawerWithViewport();
|
||||||
|
this.refreshUserProfile();
|
||||||
|
window.addEventListener("resize", this.syncDrawerWithViewport);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
window.removeEventListener("resize", this.syncDrawerWithViewport);
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleLeftDrawer() {
|
toggleLeftDrawer() {
|
||||||
this.leftDrawerOpen = !this.leftDrawerOpen;
|
this.leftDrawerOpen = !this.leftDrawerOpen;
|
||||||
},
|
},
|
||||||
toggleDarkMode() {
|
toggleDarkMode() {
|
||||||
Dark.toggle();
|
Dark.toggle();
|
||||||
|
this.darkMode = Dark.isActive;
|
||||||
},
|
},
|
||||||
|
handleQuickAction() {
|
||||||
|
if (this.quickAction?.handler) {
|
||||||
|
this.quickAction.handler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.quickAction?.to) {
|
||||||
|
this.$router.push(this.quickAction.to);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
async handleLogout() {
|
||||||
currentRouteTitle() {
|
try {
|
||||||
const route = this.$route;
|
this.logoutLoading = true;
|
||||||
return route.meta?.title || route.name || "Clipper AI";
|
|
||||||
|
await API.post("/auth/logout");
|
||||||
|
|
||||||
|
const cookieRemovalOptions = { path: "/" };
|
||||||
|
|
||||||
|
Cookies.remove("token", cookieRemovalOptions);
|
||||||
|
Cookies.remove("refresh_token", cookieRemovalOptions);
|
||||||
|
Cookies.remove("user_roles", cookieRemovalOptions);
|
||||||
|
Cookies.remove("user_profile", cookieRemovalOptions);
|
||||||
|
|
||||||
|
this.userProfile = { roles: [] };
|
||||||
|
this.leftDrawerOpen = false;
|
||||||
|
|
||||||
|
if (this.$route.path !== "/login") {
|
||||||
|
this.$router.push("/login");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
} finally {
|
||||||
|
this.logoutLoading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isLoginPage() {
|
syncDrawerWithViewport() {
|
||||||
return this.$route.path === "/login";
|
if (window.innerWidth < 1024) {
|
||||||
|
this.leftDrawerOpen = false;
|
||||||
|
} else {
|
||||||
|
this.leftDrawerOpen = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
findParentRoute() {
|
||||||
|
const currentMenu = this.$route.meta?.menu;
|
||||||
|
|
||||||
|
if (!currentMenu) {
|
||||||
|
return this.navigationRoutes.find(
|
||||||
|
(route) => route.path === this.$route.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.navigationRoutes.find(
|
||||||
|
(route) => route.meta?.menu && route.meta.menu === currentMenu
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hasPermission(route) {
|
||||||
|
const required = route.meta?.permissions;
|
||||||
|
|
||||||
|
if (!Array.isArray(required) || required.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return required.some((role) => this.userRoles.includes(role));
|
||||||
|
},
|
||||||
|
getInitialUserProfile() {
|
||||||
|
const profileCookie = Cookies.get("user_profile");
|
||||||
|
const rolesCookie = Cookies.get("user_roles");
|
||||||
|
|
||||||
|
let profileFromCookie = null;
|
||||||
|
|
||||||
|
if (profileCookie) {
|
||||||
|
try {
|
||||||
|
profileFromCookie = JSON.parse(profileCookie);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Invalid user profile cookie", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectedRawRoles = new Set();
|
||||||
|
|
||||||
|
if (Array.isArray(profileFromCookie?.roles)) {
|
||||||
|
profileFromCookie.roles.forEach((role) => collectedRawRoles.add(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(profileFromCookie?.rawRoles)) {
|
||||||
|
profileFromCookie.rawRoles.forEach((role) =>
|
||||||
|
collectedRawRoles.add(role)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolesCookie) {
|
||||||
|
try {
|
||||||
|
const parsedRoles = JSON.parse(rolesCookie);
|
||||||
|
|
||||||
|
if (Array.isArray(parsedRoles)) {
|
||||||
|
parsedRoles.forEach((role) => collectedRawRoles.add(role));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Invalid roles cookie", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedRawRoles = Array.from(collectedRawRoles);
|
||||||
|
const mappedRoles = mapRolesToAppRoles(combinedRawRoles);
|
||||||
|
|
||||||
|
if (profileFromCookie) {
|
||||||
|
return {
|
||||||
|
...profileFromCookie,
|
||||||
|
rawRoles: combinedRawRoles,
|
||||||
|
roles: mappedRoles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (mappedRoles.length) {
|
||||||
|
return {
|
||||||
|
roles: mappedRoles,
|
||||||
|
rawRoles: combinedRawRoles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = buildUserProfileFromToken(token);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return { roles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedFromToken = new Set([
|
||||||
|
...(profile.rawRoles || []),
|
||||||
|
...combinedRawRoles,
|
||||||
|
...(profile.roles || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const finalRawRoles = Array.from(combinedFromToken);
|
||||||
|
const finalRoles = mapRolesToAppRoles(finalRawRoles);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
rawRoles: finalRawRoles,
|
||||||
|
roles: finalRoles,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refreshUserProfile() {
|
||||||
|
const profile = this.getInitialUserProfile();
|
||||||
|
this.userProfile = profile || { roles: [] };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style scoped lang="scss">
|
||||||
.title {
|
.app-shell {
|
||||||
font-size: 24px;
|
background: transparent;
|
||||||
color: $primary;
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-bottom: 1px solid rgba(127, 86, 217, 0.12);
|
||||||
|
padding: 10px clamp(12px, 2vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-header {
|
||||||
|
background: rgba(16, 24, 40, 0.9);
|
||||||
|
border-bottom-color: rgba(244, 235, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: clamp(16px, 3vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: #7f56d9;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__title {
|
||||||
|
font-size: clamp(24px, 3vw, 32px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475467;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-toolbar__eyebrow {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-toolbar__title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-toolbar__subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__avatar-btn {
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__avatar-btn .q-btn__content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__icon {
|
||||||
|
background: rgba(127, 86, 217, 0.08);
|
||||||
|
color: #53389e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-toolbar__icon {
|
||||||
|
background: rgba(244, 235, 255, 0.12);
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__avatar {
|
||||||
|
background: rgba(127, 86, 217, 0.16);
|
||||||
|
color: #53389e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-toolbar__avatar {
|
||||||
|
background: rgba(244, 235, 255, 0.2);
|
||||||
|
color: #0b1120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu {
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__email {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__list .q-item {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-menu__list .q-item:not(.q-item--disabled):hover {
|
||||||
|
background: rgba(127, 86, 217, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-user-menu__name {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-user-menu__email {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-user-menu__list .q-item:not(.q-item--disabled):hover {
|
||||||
|
background: rgba(244, 235, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer {
|
||||||
|
padding: 24px 0 16px;
|
||||||
|
border-right: none;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer {
|
||||||
|
background: rgba(16, 24, 40, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__tag {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer__brand {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer__tag {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__header-icon {
|
||||||
|
color: #7f56d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__item {
|
||||||
|
border-radius: 14px;
|
||||||
|
margin: 0 12px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__item:hover {
|
||||||
|
background: rgba(127, 86, 217, 0.08);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__item--active {
|
||||||
|
background: rgba(127, 86, 217, 0.16);
|
||||||
|
color: #53389e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__caption {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__chevron {
|
||||||
|
color: #c7c9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__item:hover {
|
||||||
|
background: rgba(244, 235, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__item--active {
|
||||||
|
background: rgba(244, 235, 255, 0.18);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__label {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__caption {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__chevron {
|
||||||
|
color: rgba(244, 235, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__empty {
|
||||||
|
padding: 48px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__empty-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__empty-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__header-icon {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__empty-title {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-nav__empty-text {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__footer {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__cta {
|
||||||
|
background: rgba(127, 86, 217, 0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__cta-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #53389e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer__cta-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer__cta {
|
||||||
|
background: rgba(244, 235, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer__cta-title {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-drawer__cta-text {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.app-toolbar__menu {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-drawer {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-toolbar__actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__subtitle {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toolbar__icon,
|
||||||
|
.app-toolbar__avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
export default {
|
const ROLES = {
|
||||||
VIDEOS_LIST: "role_videos_6550",
|
ADMIN: "ADMIN",
|
||||||
|
USER: "USER",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ROLE_ALIASES = {
|
||||||
|
[ROLES.ADMIN]: [
|
||||||
|
"ADMIN",
|
||||||
|
"ROLE_ADMIN",
|
||||||
|
"REALM_ADMIN",
|
||||||
|
"MASTER_ADMIN",
|
||||||
|
"APP_ADMIN",
|
||||||
|
"CLIPPER_ADMIN",
|
||||||
|
"ADMINISTRATOR",
|
||||||
|
"SUPERUSER",
|
||||||
|
],
|
||||||
|
[ROLES.USER]: [
|
||||||
|
"USER",
|
||||||
|
"ROLE_USER",
|
||||||
|
"DEFAULT_USER",
|
||||||
|
"DEFAULT_ROLES",
|
||||||
|
"DEFAULT-ROLES",
|
||||||
|
"CLIPPER_USER",
|
||||||
|
"VIDEO_USER",
|
||||||
|
"ROLE_VIDEOS_6550",
|
||||||
|
"ROLE_VIDEOS",
|
||||||
|
"ROLE_VIEWER",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_INHERITANCE = {
|
||||||
|
[ROLES.ADMIN]: [ROLES.USER],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ROLES;
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import Cookies from "js-cookie";
|
|||||||
import { createWebHistory, createRouter } from "vue-router";
|
import { createWebHistory, createRouter } from "vue-router";
|
||||||
|
|
||||||
import roles from "@/auth/roles";
|
import roles from "@/auth/roles";
|
||||||
|
import { extractRolesFromToken } from "@/utils/keycloak";
|
||||||
|
import { hasToken, validateToken, redirectToLogin } from "@/utils/auth";
|
||||||
|
|
||||||
import Videos from "@/routes/videos";
|
import Videos from "@/routes/videos";
|
||||||
import NewVideo from "@/routes/videos/new";
|
import NewVideo from "@/routes/videos/new";
|
||||||
|
import Users from "@/routes/users/index.vue";
|
||||||
import Login from "@/routes/auth/Login";
|
import Login from "@/routes/auth/Login";
|
||||||
|
|
||||||
export const MENUS = {
|
export const MENUS = {
|
||||||
@@ -14,9 +17,29 @@ export const MENUS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getUserRoles = () => {
|
const getUserRoles = () => {
|
||||||
const rolesFromCookie = Cookies.get("user_roles"); // TODO: Tirar as permissões do usuário
|
const rolesFromCookie = Cookies.get("user_roles");
|
||||||
|
|
||||||
return rolesFromCookie ? JSON.parse(rolesFromCookie) : [];
|
if (rolesFromCookie) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rolesFromCookie);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Invalid roles cookie", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
const decodedRoles = extractRolesFromToken(token);
|
||||||
|
|
||||||
|
if (decodedRoles.length) {
|
||||||
|
Cookies.set("user_roles", JSON.stringify(decodedRoles), {
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedRoles;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
@@ -36,9 +59,16 @@ export const routes = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
title: "Vídeos",
|
title: "Vídeos",
|
||||||
permissions: [roles.VIDEOS_LIST],
|
permissions: [roles.USER, roles.ADMIN],
|
||||||
showinModal: true,
|
showinModal: true,
|
||||||
menu: MENUS.VIDEOS,
|
menu: MENUS.VIDEOS,
|
||||||
|
icon: "sym_o_video_library",
|
||||||
|
quickAction: {
|
||||||
|
label: "Novo vídeo",
|
||||||
|
to: "/videos/new",
|
||||||
|
icon: "sym_o_add",
|
||||||
|
},
|
||||||
|
order: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,9 +78,27 @@ export const routes = [
|
|||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
title: "Novo Vídeo",
|
title: "Novo Vídeo",
|
||||||
permissions: [roles.VIDEOS_LIST],
|
permissions: [roles.USER, roles.ADMIN],
|
||||||
showinModal: false,
|
showinModal: false,
|
||||||
menu: MENUS.VIDEOS,
|
menu: MENUS.VIDEOS,
|
||||||
|
icon: "sym_o_add_to_queue",
|
||||||
|
quickAction: false,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/users",
|
||||||
|
name: "Users",
|
||||||
|
component: Users,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "Usuários",
|
||||||
|
permissions: [roles.ADMIN],
|
||||||
|
showinModal: true,
|
||||||
|
menu: MENUS.USUARIOS,
|
||||||
|
icon: "sym_o_groups",
|
||||||
|
quickAction: false,
|
||||||
|
order: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,37 +140,49 @@ const hasPermission = (requiredPermissions = []) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
// if (to.meta.title) {
|
if (to.meta.title) {
|
||||||
// document.title = to.meta.title;
|
document.title = to.meta.title;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// console.log(to, from);
|
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
||||||
|
const isGuestRoute = to.matched.some((record) => record.meta.guest);
|
||||||
|
|
||||||
// // if (to.matched.some((record) => record.meta.requiresAuth)) {
|
if (requiresAuth) {
|
||||||
// if (!isAuthenticated()) {
|
if (!hasToken()) {
|
||||||
// next({
|
next({ path: "/login" });
|
||||||
// path: "/login",
|
return;
|
||||||
// });
|
}
|
||||||
|
|
||||||
// return;
|
const isValid = await validateToken();
|
||||||
// }
|
|
||||||
|
|
||||||
// // const requiredPermissions = to.meta.permissions || [];
|
if (!isValid) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// // if (requiredPermissions.length > 0 && !hasPermission(requiredPermissions)) {
|
const requiredPermissions = to.meta.permissions || [];
|
||||||
// // next({ path: "/unauthorized" });
|
|
||||||
|
|
||||||
// // return;
|
if (requiredPermissions.length > 0 && !hasPermission(requiredPermissions)) {
|
||||||
// // }
|
next({ path: "/videos" });
|
||||||
// // }
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// // if (to.matched.some((record) => record.meta.guest) && isAuthenticated()) {
|
next();
|
||||||
// // next({ path: "/" });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// // return;
|
if (isGuestRoute && hasToken()) {
|
||||||
// // }
|
const isValid = await validateToken();
|
||||||
|
|
||||||
// next();
|
if (isValid) {
|
||||||
// return;
|
next({ path: "/videos" });
|
||||||
// });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,31 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-btn
|
<q-btn
|
||||||
|
v-bind="attrs"
|
||||||
|
class="app-btn"
|
||||||
|
:class="[`app-btn--${variant}`]"
|
||||||
|
:style="variantStyles"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:label="label"
|
:label="label"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:color="color"
|
:color="resolvedColor"
|
||||||
|
:text-color="resolvedTextColor"
|
||||||
:type="type"
|
:type="type"
|
||||||
|
:flat="isFlat"
|
||||||
|
:outline="isOutline"
|
||||||
|
:unelevated="isUnelevated"
|
||||||
|
:round="round"
|
||||||
|
:rounded="rounded"
|
||||||
|
:dense="dense"
|
||||||
:disable="loading || disabled"
|
:disable="loading || disabled"
|
||||||
:text-color="textColor"
|
|
||||||
:full-width="fullWidth"
|
:full-width="fullWidth"
|
||||||
/>
|
:padding="padding"
|
||||||
|
:size="size"
|
||||||
|
:ripple="ripple"
|
||||||
|
:no-wrap="noWrap"
|
||||||
|
:href="href"
|
||||||
|
:to="to"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</q-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { computed, useAttrs } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
name: "Button",
|
name: "Button",
|
||||||
props: {
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "primary",
|
default: "primary",
|
||||||
validator: (value) => ["primary", "secondary"].includes(value),
|
|
||||||
},
|
},
|
||||||
icon: {
|
textColor: {
|
||||||
type: String,
|
type: String,
|
||||||
|
default: null,
|
||||||
},
|
},
|
||||||
label: {
|
variant: {
|
||||||
type: String,
|
type: String,
|
||||||
|
default: "filled",
|
||||||
|
validator: (value) =>
|
||||||
|
["filled", "outline", "ghost", "link"].includes(value),
|
||||||
},
|
},
|
||||||
|
icon: String,
|
||||||
|
label: String,
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -38,14 +65,131 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
textColor: {
|
|
||||||
type: String,
|
|
||||||
default: "white",
|
|
||||||
},
|
|
||||||
fullWidth: {
|
fullWidth: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dense: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
|
round: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: "md",
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
type: String,
|
||||||
|
default: "12px 20px",
|
||||||
|
},
|
||||||
|
ripple: {
|
||||||
|
type: [Boolean, Object],
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
noWrap: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
const isOutline = computed(() => props.variant === "outline");
|
||||||
|
const isGhost = computed(() => props.variant === "ghost");
|
||||||
|
const isLink = computed(() => props.variant === "link");
|
||||||
|
const isFlat = computed(() => isGhost.value || isLink.value);
|
||||||
|
const isUnelevated = computed(() => props.variant === "filled" || isGhost.value);
|
||||||
|
|
||||||
|
const resolvedColor = computed(() => props.color);
|
||||||
|
|
||||||
|
const resolvedTextColor = computed(() => {
|
||||||
|
if (props.variant === "filled") {
|
||||||
|
return props.textColor || "white";
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.textColor || props.color;
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantStyles = computed(() => {
|
||||||
|
if (props.variant === "ghost") {
|
||||||
|
const backgrounds = {
|
||||||
|
primary: "rgba(127, 86, 217, 0.12)",
|
||||||
|
accent: "rgba(83, 56, 158, 0.12)",
|
||||||
|
secondary: "rgba(244, 235, 255, 0.4)",
|
||||||
|
white: "rgba(255, 255, 255, 0.2)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hoverBackgrounds = {
|
||||||
|
primary: "rgba(83, 56, 158, 0.16)",
|
||||||
|
accent: "rgba(83, 56, 158, 0.22)",
|
||||||
|
secondary: "rgba(244, 235, 255, 0.55)",
|
||||||
|
white: "rgba(255, 255, 255, 0.28)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const background = backgrounds[props.color] || "rgba(127, 86, 217, 0.12)";
|
||||||
|
const hover = hoverBackgrounds[props.color] || "rgba(83, 56, 158, 0.18)";
|
||||||
|
const color = props.textColor || props.color;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--app-btn-ghost-bg": background,
|
||||||
|
"--app-btn-ghost-hover-bg": hover,
|
||||||
|
"--app-btn-ghost-color": color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-btn {
|
||||||
|
border-radius: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-btn:hover:not(.disabled):not(.q-btn--flat) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--app-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-btn--ghost {
|
||||||
|
background: var(--app-btn-ghost-bg, rgba(127, 86, 217, 0.12));
|
||||||
|
color: var(--app-btn-ghost-color, #53389e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-btn--ghost:hover {
|
||||||
|
background: var(--app-btn-ghost-hover-bg, rgba(83, 56, 158, 0.16));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-btn--outline {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-btn--link {
|
||||||
|
padding: 0;
|
||||||
|
min-height: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-btn--ghost {
|
||||||
|
background: rgba(244, 235, 255, 0.12);
|
||||||
|
color: var(--app-btn-ghost-color, #f4ebff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="display-value" :class="[`display-value--${orientation}`, `display-value--${variant}`]">
|
||||||
<span class="display-label">{{ label }}</span>
|
<div class="display-value__label">
|
||||||
|
<slot name="label">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</slot>
|
||||||
|
<q-icon v-if="icon" :name="icon" size="18px" class="display-value__icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="display-value__content">
|
||||||
<span>{{ value }}</span>
|
<slot>
|
||||||
|
<span :class="['display-value__text', { 'display-value__text--muted': !hasValue }]">
|
||||||
|
{{ hasValue ? value : placeholder }}
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
<small v-if="helper" class="display-value__helper">{{ helper }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { defineComponent } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
export default defineComponent({
|
defineOptions({
|
||||||
name: "DisplayValue",
|
name: "DisplayValue",
|
||||||
props: {
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
@@ -22,13 +34,117 @@ export default defineComponent({
|
|||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "—",
|
||||||
|
},
|
||||||
|
orientation: {
|
||||||
|
type: String,
|
||||||
|
default: "vertical",
|
||||||
|
validator: (value) => ["vertical", "horizontal"].includes(value),
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
validator: (value) => ["default", "highlight"].includes(value),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasValue = computed(() => props.value !== undefined && props.value !== null && props.value !== "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.display-label {
|
.display-value {
|
||||||
font-weight: bold;
|
display: flex;
|
||||||
color: gray;
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(127, 86, 217, 0.06);
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value--horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value--highlight {
|
||||||
|
background: rgba(127, 86, 217, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(83, 56, 158, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #53389e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__icon {
|
||||||
|
color: #7f56d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__text--muted {
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-value__helper {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value {
|
||||||
|
background: rgba(244, 235, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value__label {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value__icon {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value__text {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value__text--muted {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .display-value__helper {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="app-input">
|
||||||
<div v-if="label">
|
<label v-if="label" class="app-input__label">
|
||||||
<span>{{ label }} {{ required ? "*" : "" }}</span>
|
<span>{{ label }}</span>
|
||||||
</div>
|
<span v-if="required" class="app-input__required">*</span>
|
||||||
|
<span v-if="hint" class="app-input__hint">{{ hint }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<slot
|
<slot
|
||||||
name="select"
|
name="select"
|
||||||
@@ -11,6 +13,13 @@
|
|||||||
>
|
>
|
||||||
<q-select
|
<q-select
|
||||||
outlined
|
outlined
|
||||||
|
dense
|
||||||
|
:emit-value="emitValue"
|
||||||
|
:map-options="mapOptions"
|
||||||
|
:behavior="behavior"
|
||||||
|
:use-input="useInput"
|
||||||
|
:use-chips="useChips"
|
||||||
|
:fill-input="fillInput"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@update:model-value="updateModelValue"
|
@update:model-value="updateModelValue"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
@@ -18,12 +27,20 @@
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:clearable="clearable"
|
:clearable="clearable"
|
||||||
:disable="loading || disable"
|
:disable="loading || disable"
|
||||||
|
:option-label="optionLabel"
|
||||||
|
:option-value="optionValue"
|
||||||
|
:popup-content-style="popupContentStyle"
|
||||||
|
popup-cover
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
defineOptions({
|
||||||
|
name: "Dropdown",
|
||||||
|
});
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Number, Array, Object],
|
type: [String, Number, Array, Object],
|
||||||
@@ -37,6 +54,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
multiple: {
|
multiple: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -57,6 +78,45 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
emitValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
mapOptions: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: "label",
|
||||||
|
},
|
||||||
|
optionValue: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: "value",
|
||||||
|
},
|
||||||
|
popupContentStyle: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: () => ({
|
||||||
|
borderRadius: "12px",
|
||||||
|
boxShadow: "0 12px 32px rgba(16, 24, 40, 0.12)",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
|
useInput: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
fillInput: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
useChips: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
@@ -65,3 +125,38 @@ const updateModelValue = (value) => {
|
|||||||
emit("update:modelValue", value);
|
emit("update:modelValue", value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #53389e;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__required {
|
||||||
|
color: #f04438;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-input__label {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-input__hint {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,65 +1,106 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-card class="q-pa-md" flat bordered>
|
<q-card class="app-card app-table" flat bordered>
|
||||||
|
<header v-if="title || subtitle || hasActionsSlot" class="app-table__header">
|
||||||
|
<div class="app-table__heading">
|
||||||
|
<h2 v-if="title" class="app-table__title">{{ title }}</h2>
|
||||||
|
<p v-if="subtitle" class="app-table__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasActionsSlot" class="app-table__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
:key="key + '-' + pagination.perPage"
|
:key="tableKey"
|
||||||
:title="title"
|
flat
|
||||||
|
bordered
|
||||||
|
:title="undefined"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:row-key="rowKey"
|
:row-key="rowKey"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:pagination="{ rowsPerPage: pagination.perPage, page: pagination.page }"
|
:grid="grid"
|
||||||
:rows-per-page-options="[10, 25, 50, 100]"
|
:rows-per-page-options="rowsPerPageOptions"
|
||||||
flat
|
:pagination="tablePagination"
|
||||||
bordered
|
:hide-pagination="true"
|
||||||
hide-pagination
|
:virtual-scroll="virtualScroll"
|
||||||
|
:separator="separator"
|
||||||
|
class="app-table__body"
|
||||||
>
|
>
|
||||||
<template v-slot:loading>
|
<template #top>
|
||||||
|
<slot name="top" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #loading>
|
||||||
<q-inner-loading showing color="primary" />
|
<q-inner-loading showing color="primary" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:no-data>
|
<template #no-data>
|
||||||
<slot name="no-data" />
|
<div class="app-table__empty">
|
||||||
|
<q-icon name="sym_o_video_library" size="32px" color="primary" />
|
||||||
|
<div class="app-table__empty-title">Nada por aqui ainda</div>
|
||||||
|
<div class="app-table__empty-subtitle">
|
||||||
|
<slot name="empty-message">Refine os filtros ou cadastre um novo item.</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<slot name="body" v-bind="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
|
|
||||||
<div class="row q-mt-md">
|
<footer v-if="showPagination" class="app-table__footer">
|
||||||
<div class="col-2 flex items-center">
|
<div class="app-table__density">
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
v-model="internalPagination.perPage"
|
||||||
v-model="pagination.perPage"
|
dense
|
||||||
:options="[10, 25, 50, 100]"
|
borderless
|
||||||
|
:options="rowsPerPageOptions"
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
:disable="loading || rows.length === 0"
|
option-value="value"
|
||||||
|
option-label="label"
|
||||||
|
:disable="loading || !hasRows"
|
||||||
@update:model-value="updatePagination({ perPage: $event })"
|
@update:model-value="updatePagination({ perPage: $event })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-8 flex justify-center">
|
<div class="app-table__pagination">
|
||||||
<q-pagination
|
<q-pagination
|
||||||
v-model="pagination.page"
|
v-model="internalPagination.page"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="md"
|
size="md"
|
||||||
:max="pagination.totalPages"
|
:max="internalPagination.totalPages"
|
||||||
:disable="loading"
|
:disable="loading || !hasRows"
|
||||||
input
|
input
|
||||||
@update:model-value="updatePagination({ page: $event })"
|
@update:model-value="updatePagination({ page: $event })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-2 flex justify-end items-center">
|
<div class="app-table__meta">
|
||||||
<span class="text-grey-5">Total: {{ pagination.total }}</span>
|
<span>{{ metaText }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</q-card>
|
</q-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { computed, reactive, useSlots, watch } from "vue";
|
||||||
import has from "lodash/has";
|
import has from "lodash/has";
|
||||||
|
|
||||||
export default {
|
defineOptions({
|
||||||
name: "Table",
|
name: "Table",
|
||||||
props: {
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
key: {
|
key: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "table-clipperia",
|
default: "table-clipperia",
|
||||||
@@ -68,13 +109,12 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
Array.isArray(value) &&
|
Array.isArray(value) && value.every((col) => has(col, "name") && has(col, "label")),
|
||||||
value.every((col) => has(col, "name") && has(col, "label")),
|
|
||||||
},
|
},
|
||||||
rows: {
|
rows: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) => Array.isArray(value),
|
validator: Array.isArray,
|
||||||
},
|
},
|
||||||
rowName: {
|
rowName: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -84,39 +124,204 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: "id",
|
default: "id",
|
||||||
},
|
},
|
||||||
title: String,
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: false,
|
default: () => ({
|
||||||
default: {
|
|
||||||
page: 1,
|
page: 1,
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
total: 10,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
hasPrev: false,
|
hasPrev: false,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
rowsPerPageOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ label: "10 por página", value: 10 },
|
||||||
|
{ label: "25 por página", value: 25 },
|
||||||
|
{ label: "50 por página", value: 50 },
|
||||||
|
{ label: "100 por página", value: 100 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
grid: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
emits: ["update:pagination"],
|
virtualScroll: {
|
||||||
methods: {
|
type: Boolean,
|
||||||
updatePagination({ page, perPage }) {
|
default: false,
|
||||||
const newPagination = { ...this.pagination };
|
},
|
||||||
|
separator: {
|
||||||
|
type: String,
|
||||||
|
default: "horizontal",
|
||||||
|
},
|
||||||
|
hidePagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (page) {
|
const emit = defineEmits(["update:pagination"]);
|
||||||
newPagination.page = page;
|
|
||||||
}
|
const slots = useSlots();
|
||||||
if (perPage) {
|
|
||||||
newPagination.perPage = perPage;
|
const hasActionsSlot = computed(() => !!slots.actions);
|
||||||
|
const hasRows = computed(() => props.rows.length > 0);
|
||||||
|
const showPagination = computed(() => !props.hidePagination && hasRows.value);
|
||||||
|
|
||||||
|
const internalPagination = reactive({
|
||||||
|
page: props.pagination.page,
|
||||||
|
perPage: props.pagination.perPage,
|
||||||
|
total: props.pagination.total,
|
||||||
|
totalPages: props.pagination.totalPages,
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.pagination,
|
||||||
|
(newPagination) => {
|
||||||
|
internalPagination.page = newPagination.page || 1;
|
||||||
|
internalPagination.perPage = newPagination.perPage || 10;
|
||||||
|
internalPagination.total = newPagination.total || 0;
|
||||||
|
internalPagination.totalPages = newPagination.totalPages || 1;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tablePagination = computed(() => ({
|
||||||
|
page: internalPagination.page,
|
||||||
|
rowsPerPage: internalPagination.perPage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tableKey = computed(() => `${props.key}-${internalPagination.perPage}`);
|
||||||
|
|
||||||
|
const metaText = computed(() => {
|
||||||
|
if (!hasRows.value) {
|
||||||
|
return "Nenhum resultado";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit("update:pagination", newPagination);
|
return `Mostrando página ${internalPagination.page} de ${internalPagination.totalPages} · Total: ${internalPagination.total}`;
|
||||||
},
|
});
|
||||||
},
|
|
||||||
|
const updatePagination = ({ page, perPage }) => {
|
||||||
|
const nextPagination = {
|
||||||
|
...props.pagination,
|
||||||
|
page: page ?? internalPagination.page,
|
||||||
|
perPage: perPage ?? internalPagination.perPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
emit("update:pagination", nextPagination);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-table {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__body {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__empty-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__empty-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #667085;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__density {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table__meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-table__title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-table__subtitle,
|
||||||
|
body.body--dark .app-table__meta {
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-table__empty-title {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-table__empty-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,33 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="app-input">
|
||||||
<div>
|
<label v-if="label" class="app-input__label">
|
||||||
<span>{{ label }} {{ required ? "*" : "" }}</span>
|
<span>{{ label }}</span>
|
||||||
</div>
|
<span v-if="required" class="app-input__required">*</span>
|
||||||
|
<span v-if="hint" class="app-input__hint">{{ hint }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
v-bind="attrs"
|
||||||
outlined
|
outlined
|
||||||
|
dense
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@update:model-value="updateValue"
|
@update:model-value="updateValue"
|
||||||
:disabled="disabled"
|
:disable="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:type="type"
|
||||||
|
:clearable="clearable"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:maxlength="maxlength"
|
||||||
|
:counter="counter"
|
||||||
|
:autogrow="autogrow"
|
||||||
|
:rows="rows"
|
||||||
|
:debounce="debounce"
|
||||||
|
:mask="mask"
|
||||||
|
:unmasked-value="unmaskedValue"
|
||||||
:required="required"
|
:required="required"
|
||||||
|
:prefix="prefix"
|
||||||
|
:suffix="suffix"
|
||||||
|
:input-style="inputStyle"
|
||||||
|
:input-class="inputClass"
|
||||||
|
:class="['app-input__field', fieldDensity]"
|
||||||
|
@blur="emit('blur')"
|
||||||
|
@focus="emit('focus')"
|
||||||
>
|
>
|
||||||
<template v-slot:append>
|
<template #append>
|
||||||
<slot name="append"></slot>
|
<slot name="append" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:prepend>
|
<template #prepend>
|
||||||
<slot name="prepend"></slot>
|
<slot name="prepend" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #hint>
|
||||||
|
<slot name="hint" />
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { defineComponent } from "vue";
|
import { computed, useAttrs } from "vue";
|
||||||
|
|
||||||
export default defineComponent({
|
defineOptions({
|
||||||
name: "TextField",
|
name: "TextField",
|
||||||
props: {
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
default: "",
|
default: "",
|
||||||
@@ -36,6 +64,10 @@ export default defineComponent({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
required: {
|
required: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -44,16 +76,133 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
type: {
|
||||||
setup(props, { emit }) {
|
type: String,
|
||||||
|
default: "text",
|
||||||
|
},
|
||||||
|
clearable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
maxlength: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
type: [Boolean, Number],
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autogrow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 3,
|
||||||
|
},
|
||||||
|
debounce: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
mask: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
unmaskedValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
prefix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
suffix: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
inputStyle: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
inputClass: {
|
||||||
|
type: [String, Array, Object],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
density: {
|
||||||
|
type: String,
|
||||||
|
default: "comfortable",
|
||||||
|
validator: (value) => ["compact", "comfortable", "spacious"].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "blur", "focus"]);
|
||||||
|
|
||||||
|
const densityClass = {
|
||||||
|
compact: "app-input__field--compact",
|
||||||
|
comfortable: "app-input__field--comfortable",
|
||||||
|
spacious: "app-input__field--spacious",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldDensity = computed(() => densityClass[props.density]);
|
||||||
|
|
||||||
const updateValue = (value) => {
|
const updateValue = (value) => {
|
||||||
emit("update:modelValue", value);
|
emit("update:modelValue", value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const attrs = useAttrs();
|
||||||
updateValue,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #53389e;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__required {
|
||||||
|
color: #f04438;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__field--compact {
|
||||||
|
--q-field-padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__field--comfortable {
|
||||||
|
--q-field-padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input__field--spacious {
|
||||||
|
--q-field-padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-input__label {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-input__hint {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import axios from "axios";
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
export const API = axios.create({
|
export const API = axios.create({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000",
|
||||||
// process.env.NODE_ENV === "development"
|
|
||||||
// ? "https://api.clipperia.com.br"
|
|
||||||
// : "http://nestjs:3000",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
API.interceptors.request.use((config) => {
|
API.interceptors.request.use((config) => {
|
||||||
@@ -21,11 +18,11 @@ API.interceptors.request.use((config) => {
|
|||||||
API.interceptors.response.use(
|
API.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
Cookies.remove("token");
|
Cookies.remove("token");
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "@quasar/extras/material-symbols-outlined/material-symbols-outlined.css";
|
|||||||
import "@quasar/extras/mdi-v7/mdi-v7.css";
|
import "@quasar/extras/mdi-v7/mdi-v7.css";
|
||||||
|
|
||||||
import "quasar/src/css/index.sass";
|
import "quasar/src/css/index.sass";
|
||||||
|
import "./styles/global.scss";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
$primary : #8200ff
|
$primary : #7f56d9
|
||||||
$secondary : #eabdff
|
$secondary : #f4ebff
|
||||||
$accent : #8200ff
|
$accent : #53389e
|
||||||
|
|
||||||
$dark : #1c1e21
|
$dark : #101828
|
||||||
$dark-page : #1c1e21
|
$dark-page : #0b1120
|
||||||
|
|
||||||
$success : #31cb00
|
$positive : #12b76a
|
||||||
$error : #ee2e31
|
$negative : #f04438
|
||||||
$info : #226ce0
|
$info : #2e90fa
|
||||||
$warning : #fcdc4d
|
$warning : #f79009
|
||||||
|
|
||||||
|
$success : $positive
|
||||||
|
$error : $negative
|
||||||
|
|||||||
@@ -1,95 +1,134 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-layout view="hHh LpR fFf">
|
<q-page class="login-page">
|
||||||
<q-drawer
|
<div class="login-page__container">
|
||||||
v-model="leftDrawerOpen"
|
<section class="login-page__hero">
|
||||||
show-if-above
|
<div class="login-page__hero-content">
|
||||||
:width="400"
|
<div class="login-page__brand">
|
||||||
:breakpoint="700"
|
<q-avatar size="48px" color="white" text-color="primary"
|
||||||
bordered
|
>CI</q-avatar
|
||||||
side="right"
|
|
||||||
class="bg-primary text-white"
|
|
||||||
>
|
>
|
||||||
<q-scroll-area class="fit">
|
<div>
|
||||||
<div class="q-pa-md">
|
<div class="login-page__brand-title">Clipper IA</div>
|
||||||
<q-form @submit.prevent="handleLogin" class="q-mt-lg">
|
<div class="login-page__brand-subtitle">
|
||||||
<q-input
|
Cortes automáticos com inteligência
|
||||||
v-model="username"
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
label="Username"
|
</div>
|
||||||
lazy-rules
|
|
||||||
:rules="[(val) => !!val || 'Campo obrigatório']"
|
|
||||||
class="q-mb-md"
|
|
||||||
dark
|
|
||||||
filled
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="person" />
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<q-input
|
<h2>Produza cortes envolventes sem perder tempo na edição.</h2>
|
||||||
|
<p>
|
||||||
|
Envie a url do vídeo no Youtube e receba os melhores momentos
|
||||||
|
prontos para divulgar em minutos. Nossa IA identifica picos de
|
||||||
|
retenção, fala e emoção para gerar clipes certeiros.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="login-page__stats">
|
||||||
|
<div>
|
||||||
|
<span class="login-page__stat-value">+3x</span>
|
||||||
|
<span class="login-page__stat-label">mais alcance</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="login-page__stat-value">24/7</span>
|
||||||
|
<span class="login-page__stat-label">operando</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="login-card__wrapper">
|
||||||
|
<q-card flat bordered class="login-card">
|
||||||
|
<div class="login-card__header">
|
||||||
|
<div class="login-card__badge">
|
||||||
|
<q-icon name="sym_o_bolt" size="20px" />
|
||||||
|
<span>Beta exclusivo</span>
|
||||||
|
</div>
|
||||||
|
<h1>Bem-vindo de volta</h1>
|
||||||
|
<p>
|
||||||
|
Entre para continuar gerando clipes automaticamente com o Clipper
|
||||||
|
IA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-form class="login-card__form">
|
||||||
|
<TextField
|
||||||
|
v-model="username"
|
||||||
|
label="Email ou usuário"
|
||||||
|
placeholder="seuemail@clipper.ai"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_person" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
label="Senha"
|
label="Senha"
|
||||||
lazy-rules
|
required
|
||||||
:rules="[(val) => !!val || 'Campo obrigatório']"
|
placeholder="••••••••"
|
||||||
class="q-mb-lg"
|
|
||||||
dark
|
|
||||||
filled
|
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template #prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="sym_o_lock" color="primary" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #append>
|
||||||
<template v-slot:append>
|
|
||||||
<q-icon
|
<q-icon
|
||||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
:name="
|
||||||
|
showPassword ? 'sym_o_visibility_off' : 'sym_o_visibility'
|
||||||
|
"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
|
color="primary"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</TextField>
|
||||||
|
|
||||||
|
<div class="login-card__links">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
color="primary"
|
||||||
|
text-color="primary"
|
||||||
|
label="Esqueci minha senha"
|
||||||
|
@click.prevent="handleForgotPassword"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
label="Entrar"
|
label="Entrar"
|
||||||
color="white"
|
|
||||||
textColor="primary"
|
|
||||||
full-width
|
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
full-width
|
||||||
/>
|
/>
|
||||||
</q-form>
|
</q-form>
|
||||||
|
|
||||||
<p v-if="error" class="text-negative q-mt-md">{{ error }}</p>
|
<p v-if="error" class="login-card__error">{{ error }}</p>
|
||||||
</div>
|
</q-card>
|
||||||
</q-scroll-area>
|
</section>
|
||||||
</q-drawer>
|
|
||||||
|
|
||||||
<q-page-container>
|
|
||||||
<q-page class="flex flex-center bg-black-1">
|
|
||||||
<div class="text-center q-pa-md">
|
|
||||||
<div class="text-h4 q-mb-md">Clipper IA</div>
|
|
||||||
|
|
||||||
<p class="text-grey-8">Cortes automaticos de vídeos</p>
|
|
||||||
</div>
|
</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
|
||||||
</q-layout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
|
import TextField from "@components/TextField";
|
||||||
|
|
||||||
import { API } from "@config/axios";
|
import { API } from "@config/axios";
|
||||||
|
import {
|
||||||
|
buildUserProfileFromToken,
|
||||||
|
extractRolesFromToken,
|
||||||
|
} from "@utils/keycloak";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "LoginView",
|
name: "LoginView",
|
||||||
components: {
|
components: {
|
||||||
Button,
|
Button,
|
||||||
|
TextField,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
leftDrawerOpen: true,
|
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
showPassword: false,
|
showPassword: false,
|
||||||
@@ -104,12 +143,13 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.error = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
params.append("username", this.username.trim());
|
||||||
params.append("username", this.username);
|
|
||||||
params.append("password", this.password);
|
params.append("password", this.password);
|
||||||
|
|
||||||
const { data } = await API.post("/auth/login", params.toString(), {
|
const { data } = await API.post("/auth/login", params.toString(), {
|
||||||
@@ -118,63 +158,285 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accessTokenDays = data.expires_in
|
||||||
|
? data.expires_in / (60 * 60 * 24)
|
||||||
|
: undefined;
|
||||||
|
const refreshTokenDays = data.refresh_expires_in
|
||||||
|
? data.refresh_expires_in / (60 * 60 * 24)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cookieOptions = {
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: window.location.protocol === "https:",
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
Cookies.set("token", data.access_token, {
|
Cookies.set("token", data.access_token, {
|
||||||
expires: data.expires_in,
|
...cookieOptions,
|
||||||
|
expires: accessTokenDays,
|
||||||
});
|
});
|
||||||
|
|
||||||
Cookies.set("refresh_token", data.refresh_token, {
|
Cookies.set("refresh_token", data.refresh_token, {
|
||||||
expires: data.refresh_expires_in,
|
...cookieOptions,
|
||||||
|
expires: refreshTokenDays,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.$router.push("/");
|
const profile = buildUserProfileFromToken(data.access_token);
|
||||||
|
const roles = profile?.roles?.length
|
||||||
|
? profile.roles
|
||||||
|
: extractRolesFromToken(data.access_token);
|
||||||
|
|
||||||
|
if (roles?.length) {
|
||||||
|
Cookies.set("user_roles", JSON.stringify(roles), {
|
||||||
|
...cookieOptions,
|
||||||
|
expires: accessTokenDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
Cookies.set("user_profile", JSON.stringify(profile), {
|
||||||
|
...cookieOptions,
|
||||||
|
expires: accessTokenDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$router.push("/videos");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log("ok");
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: "negative",
|
||||||
|
message: "Não foi possível entrar. Verifique as credenciais.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
handleForgotPassword() {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message: "Entre em contato com o suporte para redefinir sua senha.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleSupport() {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message:
|
||||||
|
"Envie um email para suporte@clipper.ai e responderemos rapidamente.",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
if (window.innerWidth < 700) {
|
|
||||||
this.leftDrawerOpen = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped lang="scss">
|
||||||
/* Estilos personalizados para o formulário de login */
|
.login-page {
|
||||||
.q-drawer {
|
height: 100vh;
|
||||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
rgba(127, 86, 217, 0.18),
|
||||||
|
transparent 55%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at bottom right,
|
||||||
|
rgba(83, 56, 158, 0.22),
|
||||||
|
transparent 45%
|
||||||
|
),
|
||||||
|
#f5f6fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ajustes para responsividade */
|
.login-page__container {
|
||||||
@media (max-width: 700px) {
|
display: grid;
|
||||||
.q-drawer {
|
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
|
||||||
width: 100% !important;
|
width: min(1100px, 92vw);
|
||||||
|
gap: clamp(32px, 6vw, 56px);
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(32px, 5vw, 64px) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.q-page-container {
|
.login-page__hero {
|
||||||
padding-right: 0 !important;
|
color: #f8f9ff;
|
||||||
|
background: linear-gradient(160deg, #53389e 0%, #7f56d9 60%, #b692f6 100%);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: clamp(32px, 5vw, 56px);
|
||||||
|
box-shadow: 0 24px 64px rgba(83, 56, 158, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__hero h2 {
|
||||||
|
font-size: clamp(28px, 3vw, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 32px 0 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__hero p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__brand-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__brand-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: clamp(28px, 4vw, 40px);
|
||||||
|
box-shadow: 0 22px 40px rgba(16, 24, 40, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 86, 217, 0.12);
|
||||||
|
color: #53389e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header h1 {
|
||||||
|
font-size: clamp(24px, 3vw, 30px);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__error {
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #f97066;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__footer {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .login-page {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
rgba(83, 56, 158, 0.5),
|
||||||
|
transparent 55%
|
||||||
|
),
|
||||||
|
#0b1120;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .login-card {
|
||||||
|
background: rgba(16, 24, 40, 0.94);
|
||||||
|
box-shadow: 0 22px 44px rgba(12, 18, 32, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .login-card__header h1 {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .login-card__header p,
|
||||||
|
body.body--dark .login-card__footer {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.login-page__container {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilo para o botão de login */
|
@media (max-width: 640px) {
|
||||||
.q-btn--actionable {
|
.login-page {
|
||||||
transition: transform 0.2s, opacity 0.2s;
|
background: #f5f6fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.q-btn--actionable:active:not(.disabled) {
|
.login-page__container {
|
||||||
transform: scale(0.98);
|
width: 100%;
|
||||||
|
padding: 24px 16px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Melhorias na acessibilidade */
|
.login-page__hero {
|
||||||
.q-field--filled .q-field__control {
|
display: none;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Efeito de hover nos inputs */
|
.login-card {
|
||||||
.q-field--filled:not(.q-field--readonly):hover .q-field__control:before {
|
border-radius: 20px;
|
||||||
border-color: rgba(255, 255, 255, 0.7) !important;
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
694
src/routes/users/index.vue
Normal file
694
src/routes/users/index.vue
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
<template>
|
||||||
|
<div class="users-page">
|
||||||
|
<section class="app-page__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="app-page__title">Gestão de usuários</h2>
|
||||||
|
<p class="app-page__subtitle">
|
||||||
|
Controle quem pode acessar o Clipper IA, convide novos membros e
|
||||||
|
acompanhe o status de cada conta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-actions-bar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="sym_o_refresh"
|
||||||
|
label="Atualizar"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleRefresh"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="sym_o_person_add"
|
||||||
|
label="Adicionar Usuário"
|
||||||
|
@click="handleNewUser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<q-card flat bordered class="users-page__filters app-card">
|
||||||
|
<div class="users-page__filters-head">
|
||||||
|
<div>
|
||||||
|
<h3>Filtrar usuários</h3>
|
||||||
|
<span
|
||||||
|
>Busque por nome ou e-mail para localizar rapidamente qualquer
|
||||||
|
membro.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="users-page__filters-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
text-color="primary"
|
||||||
|
icon="sym_o_close"
|
||||||
|
label="Limpar filtros"
|
||||||
|
:disabled="!hasFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="users-page__filters-grid">
|
||||||
|
<TextField
|
||||||
|
v-model="filters.name"
|
||||||
|
label="Nome"
|
||||||
|
placeholder="Ex.: Ana Silva"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_person" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
v-model="filters.email"
|
||||||
|
label="E-mail"
|
||||||
|
placeholder="Ex.: ana@clipperia.com"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_alternate_email" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="users-page__filters-footer">
|
||||||
|
<Button
|
||||||
|
label="Buscar usuários"
|
||||||
|
icon="sym_o_search"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleSearch({ ...pagination, page: 1 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
key="users-table"
|
||||||
|
class="users-page__table"
|
||||||
|
title="Usuários"
|
||||||
|
subtitle="Visualize quem tem acesso ao Clipper IA e acompanhe o status de cada conta."
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
:pagination="pagination"
|
||||||
|
:loading="loading"
|
||||||
|
@update:pagination="updatePagination"
|
||||||
|
>
|
||||||
|
<template #empty-message>
|
||||||
|
Nenhum usuário encontrado para os filtros selecionados.
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<div class="users-page__cell">
|
||||||
|
<div class="users-page__cell-label">{{ col.label }}</div>
|
||||||
|
<div class="users-page__cell-value">{{ col.value }}</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- Dialog para adicionar novo usuário -->
|
||||||
|
<q-dialog v-model="showNewUserDialog" persistent>
|
||||||
|
<q-card class="new-user-dialog">
|
||||||
|
<q-card-section class="new-user-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h3 class="new-user-dialog__title">Adicionar Novo Usuário</h3>
|
||||||
|
<p class="new-user-dialog__subtitle">
|
||||||
|
Preencha os dados para criar um novo usuário no sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
icon="sym_o_close"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
v-close-popup
|
||||||
|
color="grey-7"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-section class="new-user-dialog__form">
|
||||||
|
<TextField
|
||||||
|
v-model="newUser.nome"
|
||||||
|
label="Nome"
|
||||||
|
placeholder="Ex.: Ana"
|
||||||
|
:error="!!formErrors.nome"
|
||||||
|
:error-message="formErrors.nome"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_person" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
v-model="newUser.sobrenome"
|
||||||
|
label="Sobrenome"
|
||||||
|
placeholder="Ex.: Silva"
|
||||||
|
:error="!!formErrors.sobrenome"
|
||||||
|
:error-message="formErrors.sobrenome"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_person" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
v-model="newUser.email"
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ex.: ana.silva@clipperia.com"
|
||||||
|
:error="!!formErrors.email"
|
||||||
|
:error-message="formErrors.email"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_alternate_email" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
v-model="newUser.password"
|
||||||
|
label="Senha"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="Digite uma senha segura"
|
||||||
|
:error="!!formErrors.password"
|
||||||
|
:error-message="formErrors.password"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_lock" color="primary" />
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<q-btn
|
||||||
|
:icon="
|
||||||
|
showPassword ? 'sym_o_visibility_off' : 'sym_o_visibility'
|
||||||
|
"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
color="grey-7"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-actions class="new-user-dialog__actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
label="Cancelar"
|
||||||
|
color="grey-7"
|
||||||
|
:disabled="submittingUser"
|
||||||
|
@click="closeNewUserDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Criar Usuário"
|
||||||
|
icon="sym_o_person_add"
|
||||||
|
:loading="submittingUser"
|
||||||
|
:disabled="submittingUser"
|
||||||
|
@click="handleCreateUser"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Button from "@components/Button";
|
||||||
|
import Table from "@components/Table";
|
||||||
|
import TextField from "@components/TextField";
|
||||||
|
|
||||||
|
import { API } from "@config/axios";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
label: "ID",
|
||||||
|
align: "left",
|
||||||
|
field: "id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
label: "Nome",
|
||||||
|
align: "left",
|
||||||
|
field: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email",
|
||||||
|
label: "E-mail",
|
||||||
|
align: "left",
|
||||||
|
field: "email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "role",
|
||||||
|
label: "Papel",
|
||||||
|
align: "center",
|
||||||
|
field: "role",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
label: "Status",
|
||||||
|
align: "center",
|
||||||
|
field: "status",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastAccess",
|
||||||
|
label: "Criado em",
|
||||||
|
align: "center",
|
||||||
|
field: "lastAccess",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "UsersPage",
|
||||||
|
components: {
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
TextField,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
rows: [],
|
||||||
|
loading: false,
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
direction: "asc",
|
||||||
|
perPage: 10,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrev: false,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
showNewUserDialog: false,
|
||||||
|
showPassword: false,
|
||||||
|
submittingUser: false,
|
||||||
|
newUser: {
|
||||||
|
nome: "",
|
||||||
|
sobrenome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
formErrors: {
|
||||||
|
nome: "",
|
||||||
|
sobrenome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasFilters() {
|
||||||
|
return Boolean(this.filters.name) || Boolean(this.filters.email);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.handleSearch(this.pagination);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleSearch(pagination = this.pagination) {
|
||||||
|
const basePagination = {
|
||||||
|
...this.pagination,
|
||||||
|
page: pagination.page ?? this.pagination.page,
|
||||||
|
perPage: pagination.perPage ?? this.pagination.perPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { content, pagination: nextPagination },
|
||||||
|
} = await API.get("/usuarios", {
|
||||||
|
params: {
|
||||||
|
page: basePagination.page,
|
||||||
|
perPage: basePagination.perPage,
|
||||||
|
name: this.filters.name,
|
||||||
|
email: this.filters.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rows = content.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name:
|
||||||
|
user.nome && user.sobrenome
|
||||||
|
? `${user.nome} ${user.sobrenome}`
|
||||||
|
: user.nome || user.sobrenome || "-",
|
||||||
|
email: user.email || "-",
|
||||||
|
role: this.formatRole(user.papel),
|
||||||
|
status: this.formatStatus(user.status),
|
||||||
|
lastAccess: user.criado_em || "-",
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.pagination = {
|
||||||
|
...basePagination,
|
||||||
|
...nextPagination,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "negative",
|
||||||
|
message: "Não foi possível carregar os usuários simulados.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePagination(pagination) {
|
||||||
|
this.handleSearch(pagination);
|
||||||
|
},
|
||||||
|
resetFilters() {
|
||||||
|
this.filters = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleSearch({ ...this.pagination, page: 1 });
|
||||||
|
},
|
||||||
|
handleRefresh() {
|
||||||
|
this.handleSearch({ ...this.pagination });
|
||||||
|
},
|
||||||
|
formatRole(role) {
|
||||||
|
const roles = {
|
||||||
|
USUARIO: "Usuário",
|
||||||
|
EDITOR: "Editor",
|
||||||
|
ADMINISTRADOR: "Administrador",
|
||||||
|
};
|
||||||
|
return roles[role] || role;
|
||||||
|
},
|
||||||
|
formatStatus(status) {
|
||||||
|
const statuses = {
|
||||||
|
PENDENTE: "Pendente",
|
||||||
|
ATIVO: "Ativo",
|
||||||
|
SUSPENSO: "Suspenso",
|
||||||
|
EXCLUIDO: "Excluído",
|
||||||
|
};
|
||||||
|
return statuses[status] || status;
|
||||||
|
},
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return "-";
|
||||||
|
|
||||||
|
if (dateString.includes("/")) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||||
|
},
|
||||||
|
handleNewUser() {
|
||||||
|
this.showNewUserDialog = true;
|
||||||
|
},
|
||||||
|
closeNewUserDialog() {
|
||||||
|
this.showNewUserDialog = false;
|
||||||
|
this.showPassword = false;
|
||||||
|
this.newUser = {
|
||||||
|
nome: "",
|
||||||
|
sobrenome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
this.formErrors = {
|
||||||
|
nome: "",
|
||||||
|
sobrenome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
this.formErrors = {
|
||||||
|
nome: "",
|
||||||
|
sobrenome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.newUser.nome || this.newUser.nome.trim() === "") {
|
||||||
|
this.formErrors.nome = "Nome é obrigatório";
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.newUser.nome.length > 244) {
|
||||||
|
this.formErrors.nome = "Nome deve ter no máximo 244 caracteres";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newUser.sobrenome || this.newUser.sobrenome.trim() === "") {
|
||||||
|
this.formErrors.sobrenome = "Sobrenome é obrigatório";
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.newUser.sobrenome.length > 244) {
|
||||||
|
this.formErrors.sobrenome =
|
||||||
|
"Sobrenome deve ter no máximo 244 caracteres";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newUser.email || this.newUser.email.trim() === "") {
|
||||||
|
this.formErrors.email = "E-mail é obrigatório";
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.newUser.email.length > 244) {
|
||||||
|
this.formErrors.email = "E-mail deve ter no máximo 244 caracteres";
|
||||||
|
isValid = false;
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.newUser.email)) {
|
||||||
|
this.formErrors.email = "E-mail inválido";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.newUser.password || this.newUser.password.trim() === "") {
|
||||||
|
this.formErrors.password = "Senha é obrigatória";
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.newUser.password.length < 6) {
|
||||||
|
this.formErrors.password = "Senha deve ter no mínimo 6 caracteres";
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.newUser.password.length > 244) {
|
||||||
|
this.formErrors.password = "Senha deve ter no máximo 244 caracteres";
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
},
|
||||||
|
async handleCreateUser() {
|
||||||
|
if (!this.validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.submittingUser = true;
|
||||||
|
|
||||||
|
await API.post("/usuarios", this.newUser);
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: "positive",
|
||||||
|
message: "Usuário criado com sucesso!",
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.closeNewUserDialog();
|
||||||
|
this.handleSearch({ ...this.pagination, page: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
"Não foi possível criar o usuário. Tente novamente.";
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: "negative",
|
||||||
|
message: errorMessage,
|
||||||
|
position: "top",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.submittingUser = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.users-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters span {
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__table {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__cell-label {
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__cell-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .users-page__filters h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .users-page__filters span {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .users-page__cell-value {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.users-page__filters-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-bar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-footer {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__filters-footer > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page__cell-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog {
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__subtitle {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .new-user-dialog__title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .new-user-dialog__subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.new-user-dialog {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-user-dialog__actions > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,68 +1,125 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-list q-pa-md">
|
<div class="videos-page">
|
||||||
<q-card
|
<section class="app-page__header">
|
||||||
flat
|
<div>
|
||||||
bordered
|
<h2 class="app-page__title">Biblioteca de vídeos</h2>
|
||||||
class="q-pa-sm q-mb-lg"
|
<p class="app-page__subtitle">
|
||||||
:class="{
|
Filtre os vídeos processados pela IA, acompanhe o status dos recortes e inicie novas captações
|
||||||
'bg-grey-2': !$q.dark.isActive,
|
em poucos cliques.
|
||||||
}"
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-actions-bar">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="sym_o_refresh"
|
||||||
|
label="Atualizar"
|
||||||
|
@click="handleSearch(pagination)"
|
||||||
|
/>
|
||||||
|
<Button icon="sym_o_add" label="Novo vídeo" @click="handleAddVideo" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<q-card flat bordered class="videos-page__filters app-card">
|
||||||
|
<div class="videos-page__filters-head">
|
||||||
|
<div>
|
||||||
|
<h3>Filtrar resultados</h3>
|
||||||
|
<span>Refine a busca por título, identificador ou situação.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="videos-page__filters-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
text-color="primary"
|
||||||
|
icon="sym_o_close"
|
||||||
|
label="Limpar filtros"
|
||||||
|
:disabled="!hasFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="videos-page__filters-grid">
|
||||||
|
<TextField
|
||||||
|
v-model="filters.title"
|
||||||
|
label="Título"
|
||||||
|
placeholder="Ex.: Cortes da live com convidados"
|
||||||
>
|
>
|
||||||
<div class="row q-pa-sm q-gutter-md">
|
<template #prepend>
|
||||||
<div class="col-3">
|
<q-icon name="sym_o_title" color="primary" />
|
||||||
<TextField label="Título" />
|
</template>
|
||||||
</div>
|
</TextField>
|
||||||
<div class="col-3">
|
|
||||||
<TextField label="Video ID" />
|
<TextField
|
||||||
</div>
|
v-model="filters.videoId"
|
||||||
<div class="col-3">
|
label="Video ID"
|
||||||
|
placeholder="Ex.: x9-YRAYhesI"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_confirmation_number" color="primary" />
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
v-model="select"
|
v-model="filters.situations"
|
||||||
label="Situação"
|
label="Situação"
|
||||||
clearable
|
clearable
|
||||||
:options="situations"
|
:options="situations"
|
||||||
:loading="situationsLoading"
|
:loading="situationsLoading"
|
||||||
|
multiple
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
<div class="videos-page__filters-footer">
|
||||||
<div class="col-6">
|
|
||||||
<Button
|
<Button
|
||||||
label="Buscar"
|
label="Buscar vídeos"
|
||||||
|
icon="sym_o_search"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="handleSearch(pagination)"
|
:disabled="loading"
|
||||||
fullWidth
|
@click="handleSearch({ ...pagination, page: 1 })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
|
||||||
<Button label="Teste" @click="handleTeste()" fullWidth />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
|
key="videos-table"
|
||||||
|
class="videos-page__table"
|
||||||
|
title="Resultados"
|
||||||
|
subtitle="Veja os vídeos cadastrados, a quantidade de clipes e o status de processamento."
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
key="table-videos"
|
|
||||||
@update:pagination="updatePagination"
|
@update:pagination="updatePagination"
|
||||||
>
|
>
|
||||||
<template #no-data>
|
<template #actions>
|
||||||
<div
|
<Button
|
||||||
class="full-width row flex-center q-gutter-sm"
|
variant="ghost"
|
||||||
style="font-size: 1.3em"
|
icon="sym_o_download"
|
||||||
>
|
label="Exportar CSV"
|
||||||
<span> Não há vídeos </span>
|
@click="handleExport"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty-message>
|
||||||
|
Nenhum vídeo encontrado para os filtros selecionados.
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<div class="videos-page__cell">
|
||||||
|
<div class="videos-page__cell-label">{{ col.label }}</div>
|
||||||
|
<div class="videos-page__cell-value">{{ col.value }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-page-sticky position="bottom-right" :offset="[20, 70]">
|
|
||||||
<q-btn fab icon="add" color="accent" @click="handleAddVideo" />
|
|
||||||
</q-page-sticky>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -84,13 +141,13 @@ const columns = [
|
|||||||
{
|
{
|
||||||
name: "title",
|
name: "title",
|
||||||
label: "Título",
|
label: "Título",
|
||||||
align: "center",
|
align: "left",
|
||||||
field: "title",
|
field: "title",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "clips_quantity",
|
name: "clips_quantity",
|
||||||
label: "Clipes",
|
label: "Clipes",
|
||||||
align: "left",
|
align: "center",
|
||||||
field: "clips_quantity",
|
field: "clips_quantity",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -106,7 +163,7 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "UserList",
|
name: "VideosList",
|
||||||
components: {
|
components: {
|
||||||
Button,
|
Button,
|
||||||
Table,
|
Table,
|
||||||
@@ -122,27 +179,54 @@ export default {
|
|||||||
page: 1,
|
page: 1,
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
total: 10,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
hasPrev: false,
|
hasPrev: false,
|
||||||
},
|
},
|
||||||
situations: [],
|
situations: [],
|
||||||
situationsLoading: false,
|
situationsLoading: false,
|
||||||
select: [],
|
filters: {
|
||||||
|
title: "",
|
||||||
|
videoId: "",
|
||||||
|
situations: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
computed: {
|
||||||
this.getSituation();
|
hasFilters() {
|
||||||
|
return (
|
||||||
|
!!this.filters.title ||
|
||||||
|
!!this.filters.videoId ||
|
||||||
|
(Array.isArray(this.filters.situations) && this.filters.situations.length > 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.initializePage();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async initializePage() {
|
||||||
|
await this.getSituation();
|
||||||
|
await this.handleSearch(this.pagination);
|
||||||
|
},
|
||||||
async handleSearch(pagination) {
|
async handleSearch(pagination) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const baseParams = {
|
const baseParams = {};
|
||||||
situation: this.select,
|
|
||||||
};
|
if (this.filters.title) {
|
||||||
|
baseParams.title = this.filters.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filters.videoId) {
|
||||||
|
baseParams.videoId = this.filters.videoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(this.filters.situations) && this.filters.situations.length) {
|
||||||
|
baseParams.situation = this.filters.situations;
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await API.get("/videos", {
|
const { data } = await API.get("/videos", {
|
||||||
params: {
|
params: {
|
||||||
@@ -150,6 +234,9 @@ export default {
|
|||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
...baseParams,
|
...baseParams,
|
||||||
},
|
},
|
||||||
|
paramsSerializer: {
|
||||||
|
indexes: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rows = data.content;
|
this.rows = data.content;
|
||||||
@@ -182,31 +269,138 @@ export default {
|
|||||||
this.situationsLoading = false;
|
this.situationsLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async handleTeste() {
|
|
||||||
try {
|
|
||||||
const { data } = await API.get("/videos/search", {
|
|
||||||
params: {
|
|
||||||
url: "https://www.youtube.com/watch?v=x9-YRAYhesI",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(data);
|
|
||||||
} catch (error) {
|
|
||||||
this.$q.notify({
|
|
||||||
type: "negative",
|
|
||||||
message: getErrorMessage(error, "Erro ao buscar situações"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleAddVideo() {
|
handleAddVideo() {
|
||||||
this.$router.push("/videos/new");
|
this.$router.push("/videos/new");
|
||||||
},
|
},
|
||||||
|
resetFilters() {
|
||||||
|
this.filters = {
|
||||||
|
title: "",
|
||||||
|
videoId: "",
|
||||||
|
situations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleSearch({ ...this.pagination, page: 1 });
|
||||||
|
},
|
||||||
|
handleExport() {
|
||||||
|
if (!this.rows.length) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "warning",
|
||||||
|
message: "Não há dados para exportar. Ajuste os filtros e tente novamente.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message: "Exportação em CSV disponível em breve.",
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.user-list {
|
.videos-page {
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters span {
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__table {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__cell-label {
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__cell-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .videos-page__filters h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .videos-page__filters span {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .videos-page__cell-value {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.videos-page__filters-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-bar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-footer {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__filters-footer > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videos-page__cell-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,183 +1,229 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-list q-pa-md">
|
<div class="video-create">
|
||||||
<q-card
|
<section class="app-page__header">
|
||||||
flat
|
<div>
|
||||||
bordered
|
<h2 class="app-page__title">Cadastrar novo vídeo</h2>
|
||||||
class="q-pa-sm q-mb-lg"
|
<p class="app-page__subtitle">
|
||||||
:class="{
|
Cole a URL do YouTube para que a IA analise o conteúdo, identifique os
|
||||||
'bg-grey-2': !$q.dark.isActive,
|
picos de atenção e gere sugestões de clipes automaticamente.
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-12">
|
|
||||||
<TextField label="URL do Vídeo" v-model="url" :disabled="loading" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-6">
|
|
||||||
<Button
|
|
||||||
label="Buscar Informações"
|
|
||||||
:disabled="!url || loading"
|
|
||||||
@click="handleSearch"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card
|
|
||||||
flat
|
|
||||||
bordered
|
|
||||||
class="q-pa-sm q-mb-lg"
|
|
||||||
:class="{
|
|
||||||
'bg-grey-2': !$q.dark.isActive,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div v-if="loading" class="user-list q-pa-md flex justify-center">
|
|
||||||
<q-spinner color="primary" size="3em" :thickness="10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="Object.keys(video).length" class="user-list q-pa-md">
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<span class="text-center text-h6">Vídeo</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-12">
|
|
||||||
<DisplayValue label="Título" :value="video.title" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-12">
|
|
||||||
<DisplayValue label="Descrição" :value="video.description" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue label="Video ID" :value="video.id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue label="Duração" :value="getDuration()" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue label="Data de Postagem" :value="getDateVideo()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue
|
|
||||||
label="Visualizações"
|
|
||||||
:value="convertoToNumberFormat(video.view_count)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue
|
|
||||||
label="Likes"
|
|
||||||
:value="convertoToNumberFormat(video.like_count)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue
|
|
||||||
label="Comentários"
|
|
||||||
:value="convertoToNumberFormat(video.comment_count)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-6">
|
|
||||||
<DisplayValue
|
|
||||||
label="Categorias"
|
|
||||||
:value="video.categories.join(', ')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 q-mt-md">
|
|
||||||
<div style="color: #999; font-weight: bold">Tags</div>
|
|
||||||
|
|
||||||
<div class="row q-mt-sm">
|
|
||||||
<div v-for="tag in video.tags" :key="tag">
|
|
||||||
<q-chip color="primary">{{ tag }}</q-chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-separator class="q-mt-md" />
|
|
||||||
|
|
||||||
<div class="row q-mt-md q-pa-sm">
|
|
||||||
<span class="text-center text-h6">Canal</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-pa-sm">
|
|
||||||
<div class="col-4">
|
|
||||||
<q-tooltip :offset="[-200, 0]">
|
|
||||||
Clique para abrir no youtube
|
|
||||||
</q-tooltip>
|
|
||||||
<a :href="video.channel_url" target="_blank">
|
|
||||||
<DisplayValue label="Canal" :value="video.channel" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue
|
|
||||||
label="Inscritos"
|
|
||||||
:value="convertoToNumberFormat(video.channel_follower_count)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4">
|
|
||||||
<DisplayValue label="Identificador" :value="video.channel_id" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-separator v-if="video.thumbnail" class="q-mt-md" />
|
|
||||||
|
|
||||||
<div v-if="video.thumbnail" class="row q-mt-md q-pa-sm">
|
|
||||||
<span class="text-center text-h6">Thumbnail</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="video.thumbnail" class="row q-pa-sm">
|
|
||||||
<img
|
|
||||||
:src="video.thumbnail"
|
|
||||||
alt="Thumbnail"
|
|
||||||
height="300"
|
|
||||||
width="500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="user-list q-pa-md">
|
|
||||||
<p class="text-center">
|
|
||||||
Cole uma URL e faça a busca dos dados do vídeo
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</section>
|
||||||
|
|
||||||
<q-card flat bordered>
|
<div class="video-create__grid">
|
||||||
<div class="row justify-between q-pa-sm">
|
<q-card flat bordered class="app-card video-create__card">
|
||||||
|
<header class="video-create__card-head">
|
||||||
<div>
|
<div>
|
||||||
<Button label="Voltar" color="negative" @click="handleCancel" />
|
<h3>URL do vídeo</h3>
|
||||||
|
<p>
|
||||||
|
Utilize vídeos públicos ou não listados. Links privados não são
|
||||||
|
suportados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
v-model="url"
|
||||||
|
label="Link do YouTube"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="sym_o_link" color="primary" />
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<q-btn
|
||||||
|
v-if="url"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="sym_o_close"
|
||||||
|
color="primary"
|
||||||
|
@click="url = ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<div class="video-create__actions">
|
||||||
|
<Button
|
||||||
|
label="Buscar informações"
|
||||||
|
icon="sym_o_search"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!url || loading"
|
||||||
|
@click="handleSearch"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<ul class="video-create__hints">
|
||||||
|
<li>Informe apenas o link completo do vídeo no YouTube.</li>
|
||||||
|
<li>Suporte a vídeos até 1 hora e canais com integração liberada.</li>
|
||||||
|
<li>
|
||||||
|
Após a busca, confirme os dados, selecione a qualidade, e envie para
|
||||||
|
processamento.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<transition name="fade-slide">
|
||||||
|
<q-card
|
||||||
|
v-if="loading || videoLoaded"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="video-create__preview-card"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="video-create__preview-loading">
|
||||||
|
<q-skeleton type="rect" class="video-create__thumbnail-skeleton" />
|
||||||
|
<q-skeleton type="text" width="80%" />
|
||||||
|
<q-skeleton type="text" width="60%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="video-create__preview">
|
||||||
|
<div class="video-create__thumbnail">
|
||||||
|
<img :src="videoThumbnail" :alt="video.title" />
|
||||||
|
<q-badge color="primary" class="video-create__badge">{{
|
||||||
|
formattedDuration
|
||||||
|
}}</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-create__preview-body">
|
||||||
|
<h4>{{ video.title }}</h4>
|
||||||
|
<p>{{ video.description }}</p>
|
||||||
|
<div class="video-create__preview-actions">
|
||||||
<Button
|
<Button
|
||||||
label="Cadastrar"
|
variant="ghost"
|
||||||
@click="handleSave"
|
icon="sym_o_content_copy"
|
||||||
:loading="saveLoading"
|
label="Copiar ID do vídeo"
|
||||||
:disabled="!video.id"
|
@click="
|
||||||
|
copyToClipboard(
|
||||||
|
video.id,
|
||||||
|
'Video ID copiado para a área de transferência.'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="primary"
|
||||||
|
text-color="primary"
|
||||||
|
icon="sym_o_open_in_new"
|
||||||
|
label="Abrir no YouTube"
|
||||||
|
@click="openOnYoutube"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade-slide">
|
||||||
|
<div v-if="videoLoaded" class="video-create__details">
|
||||||
|
<q-card flat bordered class="app-card video-create__section">
|
||||||
|
<header class="video-create__section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Insights do vídeo</h3>
|
||||||
|
<span>Resumo dos dados coletados diretamente da plataforma.</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="video-create__stats">
|
||||||
|
<DisplayValue
|
||||||
|
label="Visualizações"
|
||||||
|
:value="formatNumber(video.view_count)"
|
||||||
|
/>
|
||||||
|
<DisplayValue
|
||||||
|
label="Likes"
|
||||||
|
:value="formatNumber(video.like_count)"
|
||||||
|
/>
|
||||||
|
<DisplayValue
|
||||||
|
label="Comentários"
|
||||||
|
:value="formatNumber(video.comment_count)"
|
||||||
|
/>
|
||||||
|
<DisplayValue label="Postado em" :value="formattedDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-create__meta">
|
||||||
|
<DisplayValue
|
||||||
|
label="Categorias"
|
||||||
|
:value="
|
||||||
|
video.categories?.length
|
||||||
|
? video.categories.join(', ')
|
||||||
|
: 'Não informado'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<DisplayValue
|
||||||
|
label="Tags"
|
||||||
|
:value="
|
||||||
|
video.tags?.length
|
||||||
|
? `${video.tags.length} tags detectadas`
|
||||||
|
: 'Nenhuma tag localizada'
|
||||||
|
"
|
||||||
|
helper="Os melhores recortes consideram tags com termos fortes."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="video.tags?.length" class="video-create__tags">
|
||||||
|
<q-chip
|
||||||
|
v-for="tag in video.tags"
|
||||||
|
:key="tag"
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card flat bordered class="app-card video-create__section">
|
||||||
|
<header class="video-create__section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Canal</h3>
|
||||||
|
<span
|
||||||
|
>Entenda a audiência do canal para calibrar os próximos
|
||||||
|
cortes.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="video-create__meta">
|
||||||
|
<DisplayValue label="Nome" :value="video.channel" />
|
||||||
|
<DisplayValue
|
||||||
|
label="Inscritos"
|
||||||
|
:value="
|
||||||
|
video.channel_follower_count
|
||||||
|
? formatNumber(video.channel_follower_count)
|
||||||
|
: '—'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<DisplayValue
|
||||||
|
label="Uploader"
|
||||||
|
:value="video.uploader || video.channel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-create__actions">
|
||||||
|
<Dropdown
|
||||||
|
v-model="quality"
|
||||||
|
class="video-create__quality"
|
||||||
|
label="Qualidade"
|
||||||
|
:options="qualityOptions"
|
||||||
|
:emit-value="true"
|
||||||
|
:map-options="true"
|
||||||
|
:disable="saveLoading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label="Enviar para processamento"
|
||||||
|
icon="sym_o_auto_awesome"
|
||||||
|
:disabled="!video.id"
|
||||||
|
:loading="saveLoading"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -188,70 +234,85 @@ import duration from "dayjs/plugin/duration";
|
|||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import TextField from "@components/TextField";
|
import TextField from "@components/TextField";
|
||||||
import DisplayValue from "@components/DisplayValue";
|
import DisplayValue from "@components/DisplayValue";
|
||||||
|
import Dropdown from "@components/Dropdown";
|
||||||
|
|
||||||
import { API } from "@config/axios";
|
import { API } from "@config/axios";
|
||||||
import { getErrorMessage } from "@utils/axios";
|
import { getErrorMessage } from "@utils/axios";
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
// const mock = {
|
|
||||||
// id: "x9-YRAYhesI",
|
|
||||||
// title:
|
|
||||||
// "PROVEI A COMIDA DO EXÉRCITO CHINÊS - TROPA DE ELITE DA CHINA, PACOTE RARO",
|
|
||||||
// thumbnail: "https://i.ytimg.com/vi_webp/x9-YRAYhesI/maxresdefault.webp",
|
|
||||||
// description:
|
|
||||||
// "Hoje iremos experimentar o mais novo pacote de ração militar das forças de elite da China, um dos pacotes de MRE mais difíceis e raros! Vamos comparar para ver se esse pacote é superior ao do exército do Brasil!?",
|
|
||||||
// channel_id: "UCcFgREmujdPHvA7I_VOmgIA",
|
|
||||||
// channel_url: "https://www.youtube.com/channel/UCcFgREmujdPHvA7I_VOmgIA",
|
|
||||||
// duration: 1534,
|
|
||||||
// view_count: 352973,
|
|
||||||
// webpage_url: "https://www.youtube.com/watch?v=x9-YRAYhesI",
|
|
||||||
// categories: ["People & Blogs"],
|
|
||||||
// tags: [
|
|
||||||
// "area secreta",
|
|
||||||
// "mre",
|
|
||||||
// "camping",
|
|
||||||
// "balian",
|
|
||||||
// "acampamento",
|
|
||||||
// "comida do exército",
|
|
||||||
// "ração militar",
|
|
||||||
// "sobrevivi",
|
|
||||||
// "24 horas",
|
|
||||||
// "acampando",
|
|
||||||
// "comida chinesa",
|
|
||||||
// ],
|
|
||||||
// comment_count: 785,
|
|
||||||
// like_count: 26175,
|
|
||||||
// channel: "Área Secreta",
|
|
||||||
// channel_follower_count: 10700000,
|
|
||||||
// uploader: "Área Secreta",
|
|
||||||
// timestamp: 1740956718,
|
|
||||||
// };
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "NewVideo",
|
name: "NewVideo",
|
||||||
components: {
|
components: {
|
||||||
Button,
|
Button,
|
||||||
TextField,
|
TextField,
|
||||||
DisplayValue,
|
DisplayValue,
|
||||||
|
Dropdown,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
url: "",
|
url: "",
|
||||||
loading: false,
|
loading: false,
|
||||||
|
saveLoading: false,
|
||||||
|
quality: "automatic",
|
||||||
|
qualityOptions: [
|
||||||
|
{ label: "Automática", value: "automatic" },
|
||||||
|
{ label: "Alta (1080p)", value: "1080p" },
|
||||||
|
{ label: "Média (720p)", value: "720p" },
|
||||||
|
{ label: "Baixa (480p)", value: "480p" },
|
||||||
|
],
|
||||||
video: {},
|
video: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
selectedQualityLabel() {
|
||||||
|
const current = this.qualityOptions.find(
|
||||||
|
(option) => option.value === this.quality
|
||||||
|
);
|
||||||
|
|
||||||
|
return current?.label || this.qualityOptions[0]?.label || "Automática";
|
||||||
|
},
|
||||||
|
videoLoaded() {
|
||||||
|
return Object.keys(this.video).length > 0;
|
||||||
|
},
|
||||||
|
videoThumbnail() {
|
||||||
|
if (this.video.thumbnail) return this.video.thumbnail;
|
||||||
|
if (
|
||||||
|
Array.isArray(this.video.thumbnails) &&
|
||||||
|
this.video.thumbnails.length
|
||||||
|
) {
|
||||||
|
return this.video.thumbnails[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.video.id) {
|
||||||
|
return `https://i.ytimg.com/vi/${this.video.id}/hqdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "https://placehold.co/640x360/7f56d9/FFFFFF?text=Clipper+IA";
|
||||||
|
},
|
||||||
|
formattedDuration() {
|
||||||
|
if (!this.video.duration) return "—";
|
||||||
|
|
||||||
|
const videoDuration = dayjs.duration(this.video.duration, "seconds");
|
||||||
|
return videoDuration.format("HH:mm:ss");
|
||||||
|
},
|
||||||
|
formattedDate() {
|
||||||
|
if (!this.video.timestamp) return "—";
|
||||||
|
|
||||||
|
return dayjs(this.video.timestamp * 1000).format("DD/MM/YYYY HH:mm");
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleSearch() {
|
async handleSearch() {
|
||||||
if (!this.url) return;
|
if (!this.url) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.video = {};
|
||||||
|
|
||||||
const { data } = await API.get("/videos/search", {
|
const { data } = await API.get("/videos/search", {
|
||||||
params: {
|
params: {
|
||||||
url: this.url,
|
url: this.url.trim(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,40 +320,337 @@ export default {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
type: "negative",
|
type: "negative",
|
||||||
message: getErrorMessage(error, "Erro ao buscar vídeos"),
|
message: getErrorMessage(
|
||||||
|
error,
|
||||||
|
"Erro ao buscar informações do vídeo"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getDateVideo() {
|
formatNumber(number) {
|
||||||
const duration = dayjs(this.video.timestamp * 1000);
|
if (!number && number !== 0) return "—";
|
||||||
|
|
||||||
return duration.format("DD/MM/YYYY HH:mm:ss");
|
|
||||||
},
|
|
||||||
getDuration() {
|
|
||||||
const duration = dayjs.duration(this.video.duration, "seconds");
|
|
||||||
|
|
||||||
return duration.format("HH:mm:ss");
|
|
||||||
},
|
|
||||||
convertoToNumberFormat(number) {
|
|
||||||
const formatter = new Intl.NumberFormat("pt-BR", {
|
const formatter = new Intl.NumberFormat("pt-BR", {
|
||||||
maximumSignificantDigits: 3,
|
notation: "compact",
|
||||||
|
compactDisplay: "short",
|
||||||
});
|
});
|
||||||
|
|
||||||
return formatter.format(number);
|
return formatter.format(number);
|
||||||
},
|
},
|
||||||
|
copyToClipboard(value, successMessage) {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const canUseClipboard =
|
||||||
|
typeof navigator !== "undefined" && navigator.clipboard;
|
||||||
|
|
||||||
|
if (canUseClipboard) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(value)
|
||||||
|
.then(() => {
|
||||||
|
this.$q.notify({ type: "positive", message: successMessage });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "warning",
|
||||||
|
message: "Não foi possível copiar o conteúdo.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "warning",
|
||||||
|
message: "Clipboard não suportado neste dispositivo.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openOnYoutube() {
|
||||||
|
if (!this.video.webpage_url || typeof window === "undefined") return;
|
||||||
|
window.open(this.video.webpage_url, "_blank", "noopener");
|
||||||
|
},
|
||||||
|
handleExport() {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message: "Exportação disponível em breve.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleGuide() {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message:
|
||||||
|
"Confira em breve nosso guia com as melhores práticas de cortes!",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handleChannelAlerts() {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "info",
|
||||||
|
message:
|
||||||
|
"Integração com alertas do canal estará disponível em uma próxima atualização.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async handleSave() {
|
||||||
|
if (!this.video.id) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "warning",
|
||||||
|
message: "Busque um vídeo antes de enviar para processamento.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.saveLoading = true;
|
||||||
|
|
||||||
|
const { duration, id, title } = this.video;
|
||||||
|
|
||||||
|
await API.post("/videos", {
|
||||||
|
url: this.url,
|
||||||
|
qualidade: this.quality,
|
||||||
|
videoid: id,
|
||||||
|
title,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
type: "positive",
|
||||||
|
message: `Vídeo enviado para processamento`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$router.push("/videos");
|
||||||
|
} catch (error) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: "negative",
|
||||||
|
message:
|
||||||
|
"Não foi possível enviar o vídeo. Tente novamente mais tarde.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.saveLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pasteExample() {
|
||||||
|
this.url = "https://www.youtube.com/watch?v=x9-YRAYhesI";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.user-list {
|
.video-create {
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(32px, 6vw, 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.video-create__grid {
|
||||||
text-decoration: none;
|
display: grid;
|
||||||
color: inherit;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
|
||||||
|
gap: clamp(24px, 4vw, 36px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__card p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__hints {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-card {
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__thumbnail-skeleton {
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__thumbnail {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: rgba(127, 86, 217, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-body h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475467;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__preview-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: clamp(24px, 4vw, 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #101828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__section-head span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__meta {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__tags {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__actions {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-create__quality {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-active,
|
||||||
|
.fade-slide-leave-active {
|
||||||
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from,
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .video-create__card h3,
|
||||||
|
body.body--dark .video-create__preview-body h4,
|
||||||
|
body.body--dark .video-create__section-head h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .video-create__card p,
|
||||||
|
body.body--dark .video-create__preview-body p,
|
||||||
|
body.body--dark .video-create__section-head span,
|
||||||
|
body.body--dark .video-create__hints,
|
||||||
|
body.body--dark .video-create__actions {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.video-create__grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.video-create__preview-actions,
|
||||||
|
.video-create__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
139
src/styles/global.scss
Normal file
139
src/styles/global.scss
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
:root {
|
||||||
|
--app-border-radius: 16px;
|
||||||
|
--app-shadow-sm: 0 8px 18px rgba(16, 24, 40, 0.05);
|
||||||
|
--app-shadow-md: 0 12px 32px rgba(105, 65, 198, 0.12);
|
||||||
|
--app-gradient: linear-gradient(135deg, rgba(127, 86, 217, 0.12), rgba(83, 56, 158, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Roboto", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #f5f6fa;
|
||||||
|
color: #101828;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark {
|
||||||
|
background: #0b1120;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-layout__section--marginal {
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .q-layout__section--marginal {
|
||||||
|
background: rgba(16, 24, 40, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-header, .q-footer {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: var(--app-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-card {
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
border: 1px solid rgba(127, 86, 217, 0.12);
|
||||||
|
box-shadow: none;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--app-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .q-card {
|
||||||
|
background: rgba(16, 24, 40, 0.92);
|
||||||
|
border: 1px solid rgba(244, 235, 255, 0.1);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 32px clamp(16px, 4vw, 64px);
|
||||||
|
background: linear-gradient(180deg, rgba(127, 86, 217, 0.12) 0%, rgba(255, 255, 255, 0) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-page {
|
||||||
|
background: linear-gradient(180deg, rgba(83, 56, 158, 0.3) 0%, rgba(11, 17, 32, 0) 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page__title {
|
||||||
|
font-size: clamp(28px, 4vw, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #53389e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-page__title {
|
||||||
|
color: #f4ebff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page__subtitle {
|
||||||
|
max-width: 680px;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .app-page__subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .q-page-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.body--dark) .q-page-container {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-table__top {
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-table__bottom {
|
||||||
|
padding: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-table__grid-content {
|
||||||
|
border-radius: var(--app-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-pagination {
|
||||||
|
background: rgba(127, 86, 217, 0.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .q-pagination {
|
||||||
|
background: rgba(83, 56, 158, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.body--dark .q-field--filled .q-field__control {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-notification {
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--app-shadow-md);
|
||||||
|
}
|
||||||
47
src/utils/auth.js
Normal file
47
src/utils/auth.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { API } from "../config/axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the user has a valid session token
|
||||||
|
* @returns {boolean} True if token cookie exists
|
||||||
|
*/
|
||||||
|
export function hasToken() {
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
return !!token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the current token by calling the backend /check-token endpoint
|
||||||
|
* @returns {Promise<boolean>} True if token is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export async function validateToken() {
|
||||||
|
if (!hasToken()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await API.get("/auth/check-token");
|
||||||
|
return response.data?.valid === true;
|
||||||
|
} catch (error) {
|
||||||
|
// If the request fails (401, 403, network error, etc.), token is invalid
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all authentication data from cookies
|
||||||
|
*/
|
||||||
|
export function clearAuthData() {
|
||||||
|
Cookies.remove("token");
|
||||||
|
Cookies.remove("refresh_token");
|
||||||
|
Cookies.remove("user_roles");
|
||||||
|
Cookies.remove("user_profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects to login page and clears auth data
|
||||||
|
*/
|
||||||
|
export function redirectToLogin() {
|
||||||
|
clearAuthData();
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
164
src/utils/keycloak.js
Normal file
164
src/utils/keycloak.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import ROLES, { ROLE_ALIASES, ROLE_INHERITANCE } from "@/auth/roles";
|
||||||
|
|
||||||
|
const decodeBase64 = (value) => {
|
||||||
|
if (!value) return "";
|
||||||
|
|
||||||
|
let normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const pad = normalized.length % 4;
|
||||||
|
|
||||||
|
if (pad) {
|
||||||
|
normalized = normalized.padEnd(normalized.length + (4 - pad), "=");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.atob === "function") {
|
||||||
|
return globalThis.atob(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.Buffer !== "undefined") {
|
||||||
|
return globalThis.Buffer.from(normalized, "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Environment does not support base64 decoding");
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeTokenPayload = (token) => {
|
||||||
|
if (!token || typeof token !== "string") return null;
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(decodeBase64(parts[1]));
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decode token payload", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectRoles = (payload) => {
|
||||||
|
if (!payload) return [];
|
||||||
|
|
||||||
|
const roles = new Set();
|
||||||
|
|
||||||
|
if (Array.isArray(payload.realm_access?.roles)) {
|
||||||
|
payload.realm_access.roles.forEach((role) => roles.add(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceAccess = payload.resource_access || {};
|
||||||
|
Object.values(resourceAccess).forEach((resource) => {
|
||||||
|
if (Array.isArray(resource?.roles)) {
|
||||||
|
resource.roles.forEach((role) => roles.add(role));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(roles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliasEntries = Object.entries(ROLE_ALIASES).map(([canonical, aliases]) => ({
|
||||||
|
canonical,
|
||||||
|
normalized: [...new Set([canonical, ...(aliases || [])])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((alias) => alias.toString().trim().toUpperCase()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const escapeRegex = (value) =>
|
||||||
|
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
|
const matchesAlias = (role, alias) => {
|
||||||
|
if (role === alias) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = escapeRegex(alias);
|
||||||
|
const boundaryRegex = new RegExp(`(^|[\\s_:\\-])${escaped}($|[\\s_:\\-])`);
|
||||||
|
|
||||||
|
return boundaryRegex.test(role);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyInheritance = (roleSet) => {
|
||||||
|
const augmented = new Set(roleSet);
|
||||||
|
|
||||||
|
Object.entries(ROLE_INHERITANCE).forEach(([parent, children]) => {
|
||||||
|
if (augmented.has(parent)) {
|
||||||
|
children.forEach((child) => augmented.add(child));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return augmented;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapRolesToAppRoles = (rawRoles = [], { fallbackToUser = true } = {}) => {
|
||||||
|
const normalizedRoles = (Array.isArray(rawRoles) ? rawRoles : [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((role) => role.toString().trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const normalizedUpper = normalizedRoles.map((role) => role.toUpperCase());
|
||||||
|
const result = new Set();
|
||||||
|
|
||||||
|
aliasEntries.forEach(({ canonical, normalized }) => {
|
||||||
|
const match = normalizedUpper.some((role) =>
|
||||||
|
normalized.some((alias) => matchesAlias(role, alias))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
result.add(canonical);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const inherited = applyInheritance(result);
|
||||||
|
|
||||||
|
if (
|
||||||
|
inherited.size === 0 &&
|
||||||
|
fallbackToUser &&
|
||||||
|
normalizedUpper.length > 0
|
||||||
|
) {
|
||||||
|
inherited.add(ROLES.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(inherited);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractRolesFromToken = (token) => {
|
||||||
|
const payload = decodeTokenPayload(token);
|
||||||
|
const rawRoles = collectRoles(payload);
|
||||||
|
|
||||||
|
return mapRolesToAppRoles(rawRoles);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildUserProfileFromToken = (token) => {
|
||||||
|
const payload = decodeTokenPayload(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRoles = collectRoles(payload);
|
||||||
|
const appRoles = mapRolesToAppRoles(rawRoles);
|
||||||
|
const name = payload.name || payload.preferred_username || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
name,
|
||||||
|
email: payload.email || "",
|
||||||
|
username: payload.preferred_username || payload.email || name,
|
||||||
|
firstName: payload.given_name || "",
|
||||||
|
lastName: payload.family_name || "",
|
||||||
|
roles: appRoles,
|
||||||
|
rawRoles,
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractUserInitials = (name = "") => {
|
||||||
|
const sanitized = name.trim();
|
||||||
|
if (!sanitized) return "";
|
||||||
|
|
||||||
|
const parts = sanitized.split(/\s+/).slice(0, 2);
|
||||||
|
return parts
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeToken = (token) => decodeTokenPayload(token);
|
||||||
@@ -6,6 +6,16 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { quasar, transformAssetUrls } from "@quasar/vite-plugin";
|
import { quasar, transformAssetUrls } from "@quasar/vite-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 4173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src/"),
|
"@": path.resolve(__dirname, "src/"),
|
||||||
|
|||||||
Reference in New Issue
Block a user