Compare commits

17 Commits
master ... feat

Author SHA1 Message Date
LeoMortari
a511e1b30c Merge branch 'feat' of https://gitea.clipperia.com.br/admin/clipperia into feat 2025-11-04 00:08:03 -03:00
LeoMortari
d7fe497df6 Add user 2025-11-04 00:07:56 -03:00
LeoMortari
5b3a4f7462 Ajustas login e filtros de video 2025-11-03 16:31:28 -03:00
LeoMortari
97e267040b Ajusta url do dockerfile 2025-11-03 14:19:01 -03:00
LeoMortari
3400a0fdf8 Ajusta default url para https 2025-11-03 14:08:53 -03:00
LeoMortari
759a3ae0f1 Ajusta compose 2025-11-03 13:21:05 -03:00
LeoMortari
6e9327fee4 Ajusta compose 2025-11-03 01:10:15 -03:00
LeoMortari
5b5fb9d9c3 Ajusta nginx user 2025-11-03 01:06:27 -03:00
LeoMortari
0c9d10c0b0 Arruma docker compose 2025-11-03 01:02:32 -03:00
LeoMortari
8035963b75 Remove pnpm cache 2025-11-03 01:00:03 -03:00
LeoMortari
c027f6fc34 Add no cache 2025-11-03 00:59:13 -03:00
LeoMortari
f390589e1a Remove node_modules no container 2025-11-03 00:57:03 -03:00
LeoMortari
02e198d722 Ajustes do docker 2025-11-03 00:56:36 -03:00
LeoMortari
fa2b9df45b Adiciona docker e nginx 2025-11-03 00:51:14 -03:00
LeoMortari
e95d33f172 Inicio de usuários 2025-11-02 20:39:57 -03:00
LeoMortari
91c3cd42f6 Ajustes de layout 2025-11-02 00:07:56 -03:00
LeoMortari
9fafc4e6f4 Ajusta layout 2025-11-01 21:35:59 -03:00
25 changed files with 4239 additions and 638 deletions

57
.dockerignore Normal file
View 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
View 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
View 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
View 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

View File

@@ -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
View 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;
}
}

View File

@@ -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 section.items"
v-for="route in routes.filter((route) => route.meta.showinModal)" :key="route.path"
clickable clickable
v-ripple v-ripple
:to="route.path" :active="$route.path.startsWith(route.path)"
> :to="route.path"
<q-item-section>{{ route.meta.title }}</q-item-section> class="app-nav__item"
</q-item> active-class="app-nav__item--active"
</q-list> >
<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-separator
v-if="sectionIndex !== menuSections.length - 1"
spaced
inset
/>
</template>
</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>
<router-view /> <div v-if="isLoginPage">
<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() {
computed: { if (this.quickAction?.handler) {
currentRouteTitle() { this.quickAction.handler();
const route = this.$route; return;
return route.meta?.title || route.name || "Clipper AI"; }
if (this.quickAction?.to) {
this.$router.push(this.quickAction.to);
}
}, },
isLoginPage() { async handleLogout() {
return this.$route.path === "/login"; try {
this.logoutLoading = true;
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;
}
},
syncDrawerWithViewport() {
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>

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -1,51 +1,195 @@
<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: { });
color: {
type: String, const props = defineProps({
default: "primary", color: {
validator: (value) => ["primary", "secondary"].includes(value), type: String,
}, default: "primary",
icon: {
type: String,
},
label: {
type: String,
},
loading: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "button",
},
disabled: {
type: Boolean,
default: false,
},
textColor: {
type: String,
default: "white",
},
fullWidth: {
type: Boolean,
default: false,
},
}, },
}; textColor: {
type: String,
default: null,
},
variant: {
type: String,
default: "filled",
validator: (value) =>
["filled", "outline", "ghost", "link"].includes(value),
},
icon: String,
label: String,
loading: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "button",
},
disabled: {
type: Boolean,
default: false,
},
fullWidth: {
type: Boolean,
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>

View File

@@ -1,34 +1,150 @@
<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">
</div> <slot name="label">
<span>{{ label }}</span>
</slot>
<q-icon v-if="icon" :name="icon" size="18px" class="display-value__icon" />
</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: { });
label: {
type: String, const props = defineProps({
default: "", label: {
}, type: String,
value: { default: "",
type: [String, Number], },
default: "", value: {
}, type: [String, Number],
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>

View File

@@ -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>

View File

@@ -1,122 +1,327 @@
<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: { });
key: {
type: String,
default: "table-clipperia",
},
columns: {
type: Array,
required: true,
validator: (value) =>
Array.isArray(value) &&
value.every((col) => has(col, "name") && has(col, "label")),
},
rows: {
type: Array,
required: true,
validator: (value) => Array.isArray(value),
},
rowName: {
type: String,
default: "name",
},
rowKey: {
type: String,
default: "id",
},
title: String,
loading: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
required: false,
default: {
page: 1,
direction: "desc",
perPage: 10,
total: 10,
totalPages: 1,
hasNext: false,
hasPrev: false,
},
},
},
emits: ["update:pagination"],
methods: {
updatePagination({ page, perPage }) {
const newPagination = { ...this.pagination };
if (page) { const props = defineProps({
newPagination.page = page; key: {
} type: String,
if (perPage) { default: "table-clipperia",
newPagination.perPage = perPage;
}
this.$emit("update:pagination", newPagination);
},
}, },
columns: {
type: Array,
required: true,
validator: (value) =>
Array.isArray(value) && value.every((col) => has(col, "name") && has(col, "label")),
},
rows: {
type: Array,
required: true,
validator: Array.isArray,
},
rowName: {
type: String,
default: "name",
},
rowKey: {
type: String,
default: "id",
},
title: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: () => ({
page: 1,
direction: "desc",
perPage: 10,
total: 0,
totalPages: 1,
hasNext: 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,
},
virtualScroll: {
type: Boolean,
default: false,
},
separator: {
type: String,
default: "horizontal",
},
hidePagination: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:pagination"]);
const slots = useSlots();
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";
}
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>

View File

@@ -1,59 +1,208 @@
<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: { });
modelValue: {
type: [String, Number],
default: "",
},
label: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const updateValue = (value) => {
emit("update:modelValue", value);
};
return { const props = defineProps({
updateValue, modelValue: {
}; type: [String, Number],
default: "",
},
label: {
type: String,
default: "",
},
hint: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
type: {
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) => {
emit("update:modelValue", value);
};
const attrs = useAttrs();
</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>

View File

@@ -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;
} }
); );

View File

@@ -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";

View File

@@ -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

View File

@@ -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 class="q-pa-md">
<q-form @submit.prevent="handleLogin" class="q-mt-lg">
<q-input
v-model="username"
type="text"
label="Username"
lazy-rules
:rules="[(val) => !!val || 'Campo obrigatório']"
class="q-mb-md"
dark
filled
> >
<template v-slot:prepend> <div>
<q-icon name="person" /> <div class="login-page__brand-title">Clipper IA</div>
</template> <div class="login-page__brand-subtitle">
</q-input> Cortes automáticos com inteligência
</div>
</div>
</div>
<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> </div>
</q-page>
<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>
</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() {
mounted() { this.$q.notify({
if (window.innerWidth < 700) { type: "info",
this.leftDrawerOpen = false; 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.",
});
},
}, },
}; };
</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;
}
.login-page__hero {
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);
} }
.q-page-container { .login-card {
padding-right: 0 !important; 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
View 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>

View File

@@ -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="row q-pa-sm q-gutter-md">
<div class="col-3"> <div class="app-actions-bar">
<TextField label="Título" /> <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>
<div class="col-3">
<TextField label="Video ID" /> <div class="videos-page__filters-actions">
</div> <Button
<div class="col-3"> variant="ghost"
<Dropdown color="secondary"
v-model="select" text-color="primary"
label="Situação" icon="sym_o_close"
clearable label="Limpar filtros"
:options="situations" :disabled="!hasFilters"
:loading="situationsLoading" @click="resetFilters"
/> />
</div> </div>
</div> </div>
<div class="row q-pa-sm"> <div class="videos-page__filters-grid">
<div class="col-6"> <TextField
<Button v-model="filters.title"
label="Buscar" label="Título"
:loading="loading" placeholder="Ex.: Cortes da live com convidados"
@click="handleSearch(pagination)" >
fullWidth <template #prepend>
/> <q-icon name="sym_o_title" color="primary" />
</div> </template>
<div class="col-6"> </TextField>
<Button label="Teste" @click="handleTeste()" fullWidth />
</div> <TextField
v-model="filters.videoId"
label="Video ID"
placeholder="Ex.: x9-YRAYhesI"
>
<template #prepend>
<q-icon name="sym_o_confirmation_number" color="primary" />
</template>
</TextField>
<Dropdown
v-model="filters.situations"
label="Situação"
clearable
:options="situations"
:loading="situationsLoading"
multiple
emit-value
map-options
/>
</div>
<div class="videos-page__filters-footer">
<Button
label="Buscar vídeos"
icon="sym_o_search"
:loading="loading"
:disabled="loading"
@click="handleSearch({ ...pagination, page: 1 })"
/>
</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 vídeos </span> @click="handleExport"
</div> />
</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>
</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,18 +141,18 @@ 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",
}, },
{ {
name: "videoid", name: "videoid",
label: "VideoID", label: "Video ID",
field: "videoid", field: "videoid",
}, },
{ {
@@ -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>

View File

@@ -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.
}" </p>
>
<div class="row q-pa-sm">
<div class="col-12">
<TextField label="URL do Vídeo" v-model="url" :disabled="loading" />
</div>
</div> </div>
</section>
<div class="row q-pa-sm"> <div class="video-create__grid">
<div class="col-6"> <q-card flat bordered class="app-card video-create__card">
<header class="video-create__card-head">
<div>
<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 <Button
label="Buscar Informações" label="Buscar informações"
icon="sym_o_search"
:loading="loading"
:disabled="!url || loading" :disabled="!url || loading"
@click="handleSearch" @click="handleSearch"
fullWidth
/> />
</div> </div>
</div>
</q-card>
<q-card <ul class="video-create__hints">
flat <li>Informe apenas o link completo do vídeo no YouTube.</li>
bordered <li>Suporte a vídeos até 1 hora e canais com integração liberada.</li>
class="q-pa-sm q-mb-lg" <li>
:class="{ Após a busca, confirme os dados, selecione a qualidade, e envie para
'bg-grey-2': !$q.dark.isActive, processamento.
}" </li>
> </ul>
<div v-if="loading" class="user-list q-pa-md flex justify-center"> </q-card>
<q-spinner color="primary" size="3em" :thickness="10" />
</div>
<div v-else-if="Object.keys(video).length" class="user-list q-pa-md"> <transition name="fade-slide">
<div class="row q-pa-sm"> <q-card
<span class="text-center text-h6">Vídeo</span> v-if="loading || videoLoaded"
</div> flat
bordered
<div class="row q-pa-sm"> class="video-create__preview-card"
<div class="col-12"> >
<DisplayValue label="Título" :value="video.title" /> <div v-if="loading" class="video-create__preview-loading">
</div> <q-skeleton type="rect" class="video-create__thumbnail-skeleton" />
</div> <q-skeleton type="text" width="80%" />
<q-skeleton type="text" width="60%" />
<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>
<div class="col-4"> <div v-else class="video-create__preview">
<DisplayValue label="Duração" :value="getDuration()" /> <div class="video-create__thumbnail">
</div> <img :src="videoThumbnail" :alt="video.title" />
<q-badge color="primary" class="video-create__badge">{{
formattedDuration
}}</q-badge>
</div>
<div class="col-4"> <div class="video-create__preview-body">
<DisplayValue label="Data de Postagem" :value="getDateVideo()" /> <h4>{{ video.title }}</h4>
</div> <p>{{ video.description }}</p>
</div> <div class="video-create__preview-actions">
<Button
<div class="row q-pa-sm"> variant="ghost"
<div class="col-4"> icon="sym_o_content_copy"
<DisplayValue label="Copiar ID do vídeo"
label="Visualizações" @click="
:value="convertoToNumberFormat(video.view_count)" copyToClipboard(
/> video.id,
</div> 'Video ID copiado para a área de transferência.'
)
<div class="col-4"> "
<DisplayValue />
label="Likes" <Button
:value="convertoToNumberFormat(video.like_count)" variant="outline"
/> color="primary"
</div> text-color="primary"
icon="sym_o_open_in_new"
<div class="col-4"> label="Abrir no YouTube"
<DisplayValue @click="openOnYoutube"
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>
</div> </div>
</div> </q-card>
</transition>
</div>
<q-separator class="q-mt-md" /> <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="row q-mt-md q-pa-sm"> <div class="video-create__stats">
<span class="text-center text-h6">Canal</span> <DisplayValue
</div> label="Visualizações"
:value="formatNumber(video.view_count)"
<div class="row q-pa-sm"> />
<div class="col-4"> <DisplayValue
<q-tooltip :offset="[-200, 0]"> label="Likes"
Clique para abrir no youtube :value="formatNumber(video.like_count)"
</q-tooltip> />
<a :href="video.channel_url" target="_blank"> <DisplayValue
<DisplayValue label="Canal" :value="video.channel" /> label="Comentários"
</a> :value="formatNumber(video.comment_count)"
/>
<DisplayValue label="Postado em" :value="formattedDate" />
</div> </div>
<div class="col-4"> <div class="video-create__meta">
<DisplayValue <DisplayValue
label="Inscritos" label="Categorias"
:value="convertoToNumberFormat(video.channel_follower_count)" :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>
<div class="col-4"> <div v-if="video.tags?.length" class="video-create__tags">
<DisplayValue label="Identificador" :value="video.channel_id" /> <q-chip
v-for="tag in video.tags"
:key="tag"
color="primary"
text-color="white"
>
{{ tag }}
</q-chip>
</div> </div>
</div> </q-card>
<q-separator v-if="video.thumbnail" class="q-mt-md" /> <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 v-if="video.thumbnail" class="row q-mt-md q-pa-sm"> <div class="video-create__meta">
<span class="text-center text-h6">Thumbnail</span> <DisplayValue label="Nome" :value="video.channel" />
</div> <DisplayValue
label="Inscritos"
:value="
video.channel_follower_count
? formatNumber(video.channel_follower_count)
: '—'
"
/>
<DisplayValue
label="Uploader"
:value="video.uploader || video.channel"
/>
</div>
<div v-if="video.thumbnail" class="row q-pa-sm"> <div class="video-create__actions">
<img <Dropdown
:src="video.thumbnail" v-model="quality"
alt="Thumbnail" class="video-create__quality"
height="300" label="Qualidade"
width="500" :options="qualityOptions"
/> :emit-value="true"
</div> :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> </div>
</transition>
<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>
</div>
</q-card>
<q-card flat bordered>
<div class="row justify-between q-pa-sm">
<div>
<Button label="Voltar" color="negative" @click="handleCancel" />
</div>
<div>
<Button
label="Cadastrar"
@click="handleSave"
:loading="saveLoading"
:disabled="!video.id"
/>
</div>
</div>
</q-card>
</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
View 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
View 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
View 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);

View File

@@ -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/"),