Files
clipperia/src/App.vue
2025-11-01 21:35:59 -03:00

899 lines
20 KiB
Vue

<template>
<q-layout view="lHh Lpr lFf" class="app-shell">
<q-header v-if="!isLoginPage" class="app-header">
<q-toolbar class="app-toolbar">
<div class="app-toolbar__page">
<q-btn
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">
<Button
v-if="quickAction"
:icon="quickAction.icon || 'sym_o_add'"
:label="quickAction.label"
@click="handleQuickAction"
/>
<q-btn
flat
round
dense
color="primary"
icon="sym_o_notifications"
class="app-toolbar__icon"
aria-label="Notificações"
/>
<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-header>
<q-drawer
v-if="!isLoginPage"
v-model="leftDrawerOpen"
show-if-above
:width="260"
bordered
class="app-drawer"
>
<div class="app-drawer__header">
<q-avatar size="44px" color="primary" text-color="white">CI</q-avatar>
<div>
<div class="app-drawer__brand">Clipper IA</div>
<div class="app-drawer__tag">Assistente de cortes</div>
</div>
</div>
<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-item
v-for="route in section.items"
:key="route.path"
clickable
v-ripple
:active="$route.path.startsWith(route.path)"
:to="route.path"
class="app-nav__item"
active-class="app-nav__item--active"
>
<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>
<div class="app-drawer__footer">
<div class="app-drawer__cta">
<div class="app-drawer__cta-title">Precisa de ajuda?</div>
<div class="app-drawer__cta-text">
Nossa equipe está pronta para acelerar seus cortes.
</div>
<Button
color="secondary"
text-color="primary"
variant="ghost"
icon="sym_o_help"
label="Abrir suporte"
full-width
/>
</div>
</div>
</q-drawer>
<q-page-container>
<div v-if="isLoginPage">
<router-view />
</div>
<div v-else class="app-page">
<router-view />
</div>
</q-page-container>
</q-layout>
</template>
<script>
import { Dark } from "quasar";
import Cookies from "js-cookie";
import Button from "@components/Button";
import Toggle from "@components/Toggle";
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 {
name: "App",
components: {
Button,
Toggle,
},
data() {
return {
leftDrawerOpen: true,
darkMode: Dark.isActive,
logoutLoading: false,
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: {
toggleLeftDrawer() {
this.leftDrawerOpen = !this.leftDrawerOpen;
},
toggleDarkMode() {
Dark.toggle();
this.darkMode = Dark.isActive;
},
handleQuickAction() {
if (this.quickAction?.handler) {
this.quickAction.handler();
return;
}
if (this.quickAction?.to) {
this.$router.push(this.quickAction.to);
}
},
async handleLogout() {
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>
<style scoped lang="scss">
.app-shell {
background: transparent;
}
.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;
}
.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;
}
.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>