Ajusta layout
This commit is contained in:
863
src/App.vue
863
src/App.vue
@@ -1,89 +1,898 @@
|
||||
<template>
|
||||
<q-layout view="lHh lpr lff">
|
||||
<q-header v-if="!isLoginPage" reveal class="bg-primary text-white">
|
||||
<q-toolbar>
|
||||
<Button dense flat round icon="mdi-menu" @click="toggleLeftDrawer" />
|
||||
<q-toolbar-title> {{ currentRouteTitle }} </q-toolbar-title>
|
||||
<Toggle v-model="darkMode" @toggle="toggleDarkMode" />
|
||||
<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"
|
||||
show-if-above
|
||||
v-model="leftDrawerOpen"
|
||||
side="left"
|
||||
show-if-above
|
||||
:width="260"
|
||||
bordered
|
||||
class="app-drawer"
|
||||
>
|
||||
<div class="row q-pa-md items-center">
|
||||
<div class="col q-ml-xs title">
|
||||
<span>Clipper AI</span>
|
||||
<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-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
|
||||
v-for="route in routes.filter((route) => route.meta.showinModal)"
|
||||
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>{{ route.meta.title }}</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-icon
|
||||
:name="
|
||||
route.meta?.icon || section.icon || 'sym_o_dashboard'
|
||||
"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div class="app-nav__label">{{ route.meta?.title }}</div>
|
||||
<div v-if="route.meta?.description" class="app-nav__caption">
|
||||
{{ route.meta.description }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="sym_o_chevron_right"
|
||||
size="18px"
|
||||
class="app-nav__chevron"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-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 { 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 {
|
||||
name: "App",
|
||||
components: {
|
||||
Button,
|
||||
Toggle,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
leftDrawerOpen: false,
|
||||
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);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentRouteTitle() {
|
||||
const route = this.$route;
|
||||
return route.meta?.title || route.name || "Clipper AI";
|
||||
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;
|
||||
}
|
||||
},
|
||||
isLoginPage() {
|
||||
return this.$route.path === "/login";
|
||||
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 lang="scss" scoped>
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: $primary;
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
export default {
|
||||
VIDEOS_LIST: "role_videos_6550",
|
||||
const ROLES = {
|
||||
ADMIN: "ADMIN",
|
||||
USER: "USER",
|
||||
};
|
||||
|
||||
export const ROLE_ALIASES = {
|
||||
[ROLES.ADMIN]: [
|
||||
"ADMIN",
|
||||
"ROLE_ADMIN",
|
||||
"REALM_ADMIN",
|
||||
"MASTER_ADMIN",
|
||||
"APP_ADMIN",
|
||||
"CLIPPER_ADMIN",
|
||||
"ADMINISTRATOR",
|
||||
"SUPERUSER",
|
||||
],
|
||||
[ROLES.USER]: [
|
||||
"USER",
|
||||
"ROLE_USER",
|
||||
"DEFAULT_USER",
|
||||
"DEFAULT_ROLES",
|
||||
"DEFAULT-ROLES",
|
||||
"CLIPPER_USER",
|
||||
"VIDEO_USER",
|
||||
"ROLE_VIDEOS_6550",
|
||||
"ROLE_VIDEOS",
|
||||
"ROLE_VIEWER",
|
||||
],
|
||||
};
|
||||
|
||||
export const ROLE_INHERITANCE = {
|
||||
[ROLES.ADMIN]: [ROLES.USER],
|
||||
};
|
||||
|
||||
export default ROLES;
|
||||
|
||||
@@ -3,9 +3,11 @@ import Cookies from "js-cookie";
|
||||
import { createWebHistory, createRouter } from "vue-router";
|
||||
|
||||
import roles from "@/auth/roles";
|
||||
import { extractRolesFromToken } from "@/utils/keycloak";
|
||||
|
||||
import Videos from "@/routes/videos";
|
||||
import NewVideo from "@/routes/videos/new";
|
||||
import Users from "@/routes/users/index.vue";
|
||||
import Login from "@/routes/auth/Login";
|
||||
|
||||
export const MENUS = {
|
||||
@@ -14,9 +16,27 @@ export const MENUS = {
|
||||
};
|
||||
|
||||
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 = [
|
||||
@@ -36,9 +56,17 @@ export const routes = [
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Vídeos",
|
||||
permissions: [roles.VIDEOS_LIST],
|
||||
permissions: [roles.USER, roles.ADMIN],
|
||||
showinModal: true,
|
||||
menu: MENUS.VIDEOS,
|
||||
icon: "sym_o_video_library",
|
||||
description: "Acompanhe e gerencie os vídeos capturados automaticamente.",
|
||||
quickAction: {
|
||||
label: "Novo vídeo",
|
||||
to: "/videos/new",
|
||||
icon: "sym_o_add",
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,9 +76,29 @@ export const routes = [
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Novo Vídeo",
|
||||
permissions: [roles.VIDEOS_LIST],
|
||||
permissions: [roles.USER, roles.ADMIN],
|
||||
showinModal: false,
|
||||
menu: MENUS.VIDEOS,
|
||||
icon: "sym_o_add_to_queue",
|
||||
description: "Cole o link do YouTube e deixe a IA preparar seus cortes.",
|
||||
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",
|
||||
description: "Gerencie quem tem acesso ao Clipper IA.",
|
||||
quickAction: false,
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
<template>
|
||||
<q-btn
|
||||
v-bind="attrs"
|
||||
class="app-btn"
|
||||
:class="[`app-btn--${variant}`]"
|
||||
:style="variantStyles"
|
||||
:icon="icon"
|
||||
:label="label"
|
||||
:loading="loading"
|
||||
:color="color"
|
||||
:color="resolvedColor"
|
||||
:text-color="resolvedTextColor"
|
||||
:type="type"
|
||||
:flat="isFlat"
|
||||
:outline="isOutline"
|
||||
:unelevated="isUnelevated"
|
||||
:round="round"
|
||||
:rounded="rounded"
|
||||
:dense="dense"
|
||||
:disable="loading || disabled"
|
||||
:text-color="textColor"
|
||||
:full-width="fullWidth"
|
||||
/>
|
||||
:padding="padding"
|
||||
:size="size"
|
||||
:ripple="ripple"
|
||||
:no-wrap="noWrap"
|
||||
:href="href"
|
||||
:to="to"
|
||||
>
|
||||
<slot />
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script setup>
|
||||
import { computed, useAttrs } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "Button",
|
||||
props: {
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
validator: (value) => ["primary", "secondary"].includes(value),
|
||||
},
|
||||
icon: {
|
||||
textColor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
variant: {
|
||||
type: String,
|
||||
default: "filled",
|
||||
validator: (value) =>
|
||||
["filled", "outline", "ghost", "link"].includes(value),
|
||||
},
|
||||
icon: String,
|
||||
label: String,
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -38,14 +65,131 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default: "white",
|
||||
},
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.app-btn {
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.app-btn:hover:not(.disabled):not(.q-btn--flat) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
}
|
||||
|
||||
.app-btn--ghost {
|
||||
background: var(--app-btn-ghost-bg, rgba(127, 86, 217, 0.12));
|
||||
color: var(--app-btn-ghost-color, #53389e);
|
||||
}
|
||||
|
||||
.app-btn--ghost:hover {
|
||||
background: var(--app-btn-ghost-hover-bg, rgba(83, 56, 158, 0.16));
|
||||
}
|
||||
|
||||
.app-btn--outline {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.app-btn--link {
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.body--dark .app-btn--ghost {
|
||||
background: rgba(244, 235, 255, 0.12);
|
||||
color: var(--app-btn-ghost-color, #f4ebff);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="display-label">{{ label }}</span>
|
||||
<div class="display-value" :class="[`display-value--${orientation}`, `display-value--${variant}`]">
|
||||
<div class="display-value__label">
|
||||
<slot name="label">
|
||||
<span>{{ label }}</span>
|
||||
</slot>
|
||||
<q-icon v-if="icon" :name="icon" size="18px" class="display-value__icon" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{{ value }}</span>
|
||||
<div class="display-value__content">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
defineOptions({
|
||||
name: "DisplayValue",
|
||||
props: {
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
@@ -22,13 +34,117 @@ export default defineComponent({
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.display-label {
|
||||
font-weight: bold;
|
||||
color: gray;
|
||||
.display-value {
|
||||
display: flex;
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="label">
|
||||
<span>{{ label }} {{ required ? "*" : "" }}</span>
|
||||
</div>
|
||||
<div class="app-input">
|
||||
<label v-if="label" class="app-input__label">
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="required" class="app-input__required">*</span>
|
||||
<span v-if="hint" class="app-input__hint">{{ hint }}</span>
|
||||
</label>
|
||||
|
||||
<slot
|
||||
name="select"
|
||||
@@ -11,6 +13,13 @@
|
||||
>
|
||||
<q-select
|
||||
outlined
|
||||
dense
|
||||
:emit-value="emitValue"
|
||||
:map-options="mapOptions"
|
||||
:behavior="behavior"
|
||||
:use-input="useInput"
|
||||
:use-chips="useChips"
|
||||
:fill-input="fillInput"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="updateModelValue"
|
||||
:multiple="multiple"
|
||||
@@ -18,12 +27,20 @@
|
||||
:loading="loading"
|
||||
:clearable="clearable"
|
||||
:disable="loading || disable"
|
||||
:option-label="optionLabel"
|
||||
:option-value="optionValue"
|
||||
:popup-content-style="popupContentStyle"
|
||||
popup-cover
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: "Dropdown",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Array, Object],
|
||||
@@ -37,6 +54,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -57,6 +78,45 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
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"]);
|
||||
@@ -65,3 +125,38 @@ const updateModelValue = (value) => {
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-input__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #53389e;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.app-input__required {
|
||||
color: #f04438;
|
||||
}
|
||||
|
||||
.app-input__hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
body.body--dark .app-input__label {
|
||||
color: #f4ebff;
|
||||
}
|
||||
|
||||
body.body--dark .app-input__hint {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,106 @@
|
||||
<template>
|
||||
<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
|
||||
:key="key + '-' + pagination.perPage"
|
||||
:title="title"
|
||||
:key="tableKey"
|
||||
flat
|
||||
bordered
|
||||
:title="undefined"
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
:row-key="rowKey"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: pagination.perPage, page: pagination.page }"
|
||||
:rows-per-page-options="[10, 25, 50, 100]"
|
||||
flat
|
||||
bordered
|
||||
hide-pagination
|
||||
:grid="grid"
|
||||
:rows-per-page-options="rowsPerPageOptions"
|
||||
:pagination="tablePagination"
|
||||
:hide-pagination="true"
|
||||
: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" />
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<slot name="no-data" />
|
||||
<template #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>
|
||||
</q-table>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-2 flex items-center">
|
||||
<footer v-if="showPagination" class="app-table__footer">
|
||||
<div class="app-table__density">
|
||||
<q-select
|
||||
filled
|
||||
v-model="pagination.perPage"
|
||||
:options="[10, 25, 50, 100]"
|
||||
v-model="internalPagination.perPage"
|
||||
dense
|
||||
borderless
|
||||
:options="rowsPerPageOptions"
|
||||
emit-value
|
||||
map-options
|
||||
:disable="loading || rows.length === 0"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
:disable="loading || !hasRows"
|
||||
@update:model-value="updatePagination({ perPage: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-8 flex justify-center">
|
||||
<div class="app-table__pagination">
|
||||
<q-pagination
|
||||
v-model="pagination.page"
|
||||
v-model="internalPagination.page"
|
||||
color="primary"
|
||||
size="md"
|
||||
:max="pagination.totalPages"
|
||||
:disable="loading"
|
||||
:max="internalPagination.totalPages"
|
||||
:disable="loading || !hasRows"
|
||||
input
|
||||
@update:model-value="updatePagination({ page: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-2 flex justify-end items-center">
|
||||
<span class="text-grey-5">Total: {{ pagination.total }}</span>
|
||||
</div>
|
||||
<div class="app-table__meta">
|
||||
<span>{{ metaText }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, reactive, useSlots, watch } from "vue";
|
||||
import has from "lodash/has";
|
||||
|
||||
export default {
|
||||
defineOptions({
|
||||
name: "Table",
|
||||
props: {
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
key: {
|
||||
type: String,
|
||||
default: "table-clipperia",
|
||||
@@ -68,13 +109,12 @@ export default {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (value) =>
|
||||
Array.isArray(value) &&
|
||||
value.every((col) => has(col, "name") && has(col, "label")),
|
||||
Array.isArray(value) && value.every((col) => has(col, "name") && has(col, "label")),
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (value) => Array.isArray(value),
|
||||
validator: Array.isArray,
|
||||
},
|
||||
rowName: {
|
||||
type: String,
|
||||
@@ -84,39 +124,204 @@ export default {
|
||||
type: String,
|
||||
default: "id",
|
||||
},
|
||||
title: String,
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: {
|
||||
default: () => ({
|
||||
page: 1,
|
||||
direction: "desc",
|
||||
perPage: 10,
|
||||
total: 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,
|
||||
},
|
||||
emits: ["update:pagination"],
|
||||
methods: {
|
||||
updatePagination({ page, perPage }) {
|
||||
const newPagination = { ...this.pagination };
|
||||
virtualScroll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
separator: {
|
||||
type: String,
|
||||
default: "horizontal",
|
||||
},
|
||||
hidePagination: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (page) {
|
||||
newPagination.page = page;
|
||||
}
|
||||
if (perPage) {
|
||||
newPagination.perPage = perPage;
|
||||
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";
|
||||
}
|
||||
|
||||
this.$emit("update:pagination", newPagination);
|
||||
},
|
||||
},
|
||||
return `Mostrando página ${internalPagination.page} de ${internalPagination.totalPages} · Total: ${internalPagination.total}`;
|
||||
});
|
||||
|
||||
const updatePagination = ({ page, perPage }) => {
|
||||
const nextPagination = {
|
||||
...props.pagination,
|
||||
page: page ?? internalPagination.page,
|
||||
perPage: perPage ?? internalPagination.perPage,
|
||||
};
|
||||
|
||||
emit("update:pagination", nextPagination);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-table {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-table__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.app-table__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-table__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-table__subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.app-table__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-table__body {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-table__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px 16px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.app-table__empty-title {
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.app-table__empty-subtitle {
|
||||
font-size: 14px;
|
||||
color: #667085;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-table__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-table__density {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.app-table__meta {
|
||||
font-size: 14px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
body.body--dark .app-table__title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.body--dark .app-table__subtitle,
|
||||
body.body--dark .app-table__meta {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.body--dark .app-table__empty-title {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
body.body--dark .app-table__empty-subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<span>{{ label }} {{ required ? "*" : "" }}</span>
|
||||
</div>
|
||||
<div class="app-input">
|
||||
<label v-if="label" class="app-input__label">
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="required" class="app-input__required">*</span>
|
||||
<span v-if="hint" class="app-input__hint">{{ hint }}</span>
|
||||
</label>
|
||||
|
||||
<q-input
|
||||
v-bind="attrs"
|
||||
outlined
|
||||
dense
|
||||
:model-value="modelValue"
|
||||
@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"
|
||||
: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>
|
||||
<slot name="append"></slot>
|
||||
<template #append>
|
||||
<slot name="append" />
|
||||
</template>
|
||||
|
||||
<template v-slot:prepend>
|
||||
<slot name="prepend"></slot>
|
||||
<template #prepend>
|
||||
<slot name="prepend" />
|
||||
</template>
|
||||
|
||||
<template #hint>
|
||||
<slot name="hint" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "vue";
|
||||
<script setup>
|
||||
import { computed, useAttrs } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
defineOptions({
|
||||
name: "TextField",
|
||||
props: {
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
@@ -36,6 +64,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -44,16 +76,133 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
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);
|
||||
};
|
||||
|
||||
return {
|
||||
updateValue,
|
||||
};
|
||||
},
|
||||
});
|
||||
const attrs = useAttrs();
|
||||
</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>
|
||||
|
||||
@@ -26,6 +26,6 @@ API.interceptors.response.use(
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import "@quasar/extras/material-symbols-outlined/material-symbols-outlined.css";
|
||||
import "@quasar/extras/mdi-v7/mdi-v7.css";
|
||||
|
||||
import "quasar/src/css/index.sass";
|
||||
import "./styles/global.scss";
|
||||
|
||||
import App from "./App.vue";
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
$primary : #8200ff
|
||||
$secondary : #eabdff
|
||||
$accent : #8200ff
|
||||
$primary : #7f56d9
|
||||
$secondary : #f4ebff
|
||||
$accent : #53389e
|
||||
|
||||
$dark : #1c1e21
|
||||
$dark-page : #1c1e21
|
||||
$dark : #101828
|
||||
$dark-page : #0b1120
|
||||
|
||||
$success : #31cb00
|
||||
$error : #ee2e31
|
||||
$info : #226ce0
|
||||
$warning : #fcdc4d
|
||||
$positive : #12b76a
|
||||
$negative : #f04438
|
||||
$info : #2e90fa
|
||||
$warning : #f79009
|
||||
|
||||
$success : $positive
|
||||
$error : $negative
|
||||
|
||||
@@ -1,95 +1,147 @@
|
||||
<template>
|
||||
<q-layout view="hHh LpR fFf">
|
||||
<q-drawer
|
||||
v-model="leftDrawerOpen"
|
||||
show-if-above
|
||||
:width="400"
|
||||
:breakpoint="700"
|
||||
bordered
|
||||
side="right"
|
||||
class="bg-primary text-white"
|
||||
<q-page class="login-page">
|
||||
<div class="login-page__container">
|
||||
<section class="login-page__hero">
|
||||
<div class="login-page__hero-content">
|
||||
<div class="login-page__brand">
|
||||
<q-avatar size="48px" color="white" text-color="primary"
|
||||
>CI</q-avatar
|
||||
>
|
||||
<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>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
<div>
|
||||
<div class="login-page__brand-title">Clipper IA</div>
|
||||
<div class="login-page__brand-subtitle">
|
||||
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"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
label="Senha"
|
||||
lazy-rules
|
||||
:rules="[(val) => !!val || 'Campo obrigatório']"
|
||||
class="q-mb-lg"
|
||||
dark
|
||||
filled
|
||||
required
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
<template #prepend>
|
||||
<q-icon name="sym_o_lock" color="primary" />
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<template #append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
:name="
|
||||
showPassword ? 'sym_o_visibility_off' : 'sym_o_visibility'
|
||||
"
|
||||
class="cursor-pointer"
|
||||
color="primary"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</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
|
||||
type="submit"
|
||||
label="Entrar"
|
||||
color="white"
|
||||
textColor="primary"
|
||||
full-width
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
full-width
|
||||
/>
|
||||
</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 class="login-card__footer">
|
||||
<span>Precisa de ajuda?</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_chat"
|
||||
label="Falar com suporte"
|
||||
full-width
|
||||
@click.prevent="handleSupport"
|
||||
/>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<q-page class="flex flex-center bg-black-1">
|
||||
<div class="text-center q-pa-md">
|
||||
<div class="text-h4 q-mb-md">Clipper IA</div>
|
||||
|
||||
<p class="text-grey-8">Cortes automaticos de vídeos</p>
|
||||
</q-card>
|
||||
</section>
|
||||
</div>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import TextField from "@components/TextField";
|
||||
|
||||
import { API } from "@config/axios";
|
||||
import {
|
||||
buildUserProfileFromToken,
|
||||
extractRolesFromToken,
|
||||
} from "@utils/keycloak";
|
||||
|
||||
export default {
|
||||
name: "LoginView",
|
||||
components: {
|
||||
Button,
|
||||
TextField,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
leftDrawerOpen: true,
|
||||
username: "",
|
||||
password: "",
|
||||
showPassword: false,
|
||||
@@ -104,12 +156,13 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = "";
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append("username", this.username);
|
||||
params.append("username", this.username.trim());
|
||||
params.append("password", this.password);
|
||||
|
||||
const { data } = await API.post("/auth/login", params.toString(), {
|
||||
@@ -118,63 +171,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, {
|
||||
expires: data.expires_in,
|
||||
...cookieOptions,
|
||||
expires: accessTokenDays,
|
||||
});
|
||||
|
||||
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) {
|
||||
console.log(error);
|
||||
console.log("ok");
|
||||
|
||||
this.$q.notify({
|
||||
type: "negative",
|
||||
message: "Não foi possível entrar. Verifique as credenciais.",
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
handleForgotPassword() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message: "Entre em contato com o suporte para redefinir sua senha.",
|
||||
});
|
||||
},
|
||||
handleSupport() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message:
|
||||
"Envie um email para suporte@clipper.ai e responderemos rapidamente.",
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
if (window.innerWidth < 700) {
|
||||
this.leftDrawerOpen = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Estilos personalizados para o formulário de login */
|
||||
.q-drawer {
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
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 */
|
||||
@media (max-width: 700px) {
|
||||
.q-drawer {
|
||||
width: 100% !important;
|
||||
.login-page__container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
|
||||
width: min(1100px, 92vw);
|
||||
gap: clamp(32px, 6vw, 56px);
|
||||
align-items: center;
|
||||
padding: clamp(32px, 5vw, 64px) 0;
|
||||
}
|
||||
|
||||
.q-page-container {
|
||||
padding-right: 0 !important;
|
||||
.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);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Estilo para o botão de login */
|
||||
.q-btn--actionable {
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.q-btn--actionable:active:not(.disabled) {
|
||||
transform: scale(0.98);
|
||||
.login-page__container {
|
||||
width: 100%;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
|
||||
/* Melhorias na acessibilidade */
|
||||
.q-field--filled .q-field__control {
|
||||
border-radius: 8px;
|
||||
.login-page__hero {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Efeito de hover nos inputs */
|
||||
.q-field--filled:not(.q-field--readonly):hover .q-field__control:before {
|
||||
border-color: rgba(255, 255, 255, 0.7) !important;
|
||||
.login-card {
|
||||
border-radius: 20px;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
347
src/routes/users/index.vue
Normal file
347
src/routes/users/index.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<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
|
||||
icon="sym_o_person_add"
|
||||
label="Convidar usuário"
|
||||
@click="handleInvite"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_download"
|
||||
label="Exportar"
|
||||
@click="handleExport"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="users-page__grid">
|
||||
<q-card flat bordered class="app-card users-page__card">
|
||||
<header class="users-page__card-head">
|
||||
<div>
|
||||
<h3>Times e permissões</h3>
|
||||
<span>Use os níveis de acesso para definir o que cada grupo pode operar.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_settings"
|
||||
label="Configurar permissões"
|
||||
@click="handlePermissions"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="users-page__roles">
|
||||
<q-item v-for="role in roles" :key="role.name" class="users-page__role-item">
|
||||
<q-item-section avatar>
|
||||
<q-avatar :color="role.color" text-color="white" icon="sym_o_badge" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ role.name }}</q-item-label>
|
||||
<q-item-label caption>{{ role.description }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-chip outline color="primary" :label="role.membersLabel" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card flat bordered class="app-card users-page__card">
|
||||
<header class="users-page__card-head">
|
||||
<div>
|
||||
<h3>Status da equipe</h3>
|
||||
<span>Indicadores rápidos para acompanhar convites e acessos recentes.</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="users-page__stats">
|
||||
<div v-for="stat in stats" :key="stat.label" class="users-page__stat">
|
||||
<div class="users-page__stat-label">{{ stat.label }}</div>
|
||||
<div class="users-page__stat-value">{{ stat.value }}</div>
|
||||
<div class="users-page__stat-caption">{{ stat.caption }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-page__cta">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="sym_o_smart_toy"
|
||||
label="Automatizar aprovações"
|
||||
@click="handleWorkflow"
|
||||
/>
|
||||
<Button
|
||||
icon="sym_o_notifications_active"
|
||||
label="Ativar alertas"
|
||||
@click="handleAlerts"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-card flat bordered class="app-card users-page__roadmap">
|
||||
<header class="users-page__card-head">
|
||||
<div>
|
||||
<h3>Próximas entregas</h3>
|
||||
<span>Fique de olho nos recursos que estão chegando para a gestão da sua equipe.</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="users-page__roadmap-list">
|
||||
<li v-for="item in roadmap" :key="item.title">
|
||||
<div class="users-page__roadmap-title">{{ item.title }}</div>
|
||||
<p class="users-page__roadmap-description">{{ item.description }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Button from "@components/Button";
|
||||
|
||||
export default {
|
||||
name: "UsersPage",
|
||||
components: {
|
||||
Button,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
roles: [
|
||||
{
|
||||
name: "Administradores",
|
||||
description: "Acesso total às configurações, convites e billing.",
|
||||
membersLabel: "3 membros",
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
name: "Editores",
|
||||
description: "Podem criar cortes, revisar sugestões e publicar.",
|
||||
membersLabel: "8 membros",
|
||||
color: "secondary",
|
||||
},
|
||||
{
|
||||
name: "Leitores",
|
||||
description: "Acompanham relatórios e status dos clipes gerados.",
|
||||
membersLabel: "12 membros",
|
||||
color: "accent",
|
||||
},
|
||||
],
|
||||
stats: [
|
||||
{
|
||||
label: "Convites pendentes",
|
||||
value: "04",
|
||||
caption: "Envie lembretes automáticos em 1 clique",
|
||||
},
|
||||
{
|
||||
label: "Último acesso",
|
||||
value: "há 2h",
|
||||
caption: "Bruna adicionou novos cortes",
|
||||
},
|
||||
{
|
||||
label: "Contas bloqueadas",
|
||||
value: "01",
|
||||
caption: "Revise motivos e restaure acessos",
|
||||
},
|
||||
],
|
||||
roadmap: [
|
||||
{
|
||||
title: "Fluxo de aprovação com múltiplos níveis",
|
||||
description: "Defina revisores responsáveis por validar cortes antes da publicação automática.",
|
||||
},
|
||||
{
|
||||
title: "Integração com Slack / Discord",
|
||||
description: "Envie alertas de novos convites e aprovações direto para os canais do time.",
|
||||
},
|
||||
{
|
||||
title: "Perfis avançados",
|
||||
description: "Crie papéis personalizados combinando permissões de forma granular.",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInvite() {
|
||||
this.$q.notify({
|
||||
type: "positive",
|
||||
message: "Convites em lote estarão disponíveis em breve.",
|
||||
});
|
||||
},
|
||||
handlePermissions() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message: "Configurações avançadas de permissões ainda não foram conectadas ao backend.",
|
||||
});
|
||||
},
|
||||
handleExport() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message: "Exportação de usuários será liberada após a integração com o Keycloak.",
|
||||
});
|
||||
},
|
||||
handleWorkflow() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message: "Automação de aprovações está em desenvolvimento.",
|
||||
});
|
||||
},
|
||||
handleAlerts() {
|
||||
this.$q.notify({
|
||||
type: "info",
|
||||
message: "Em breve você poderá receber alertas no email ou no Slack.",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(32px, 6vw, 48px);
|
||||
}
|
||||
|
||||
.users-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: clamp(24px, 4vw, 32px);
|
||||
}
|
||||
|
||||
.users-page__card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.users-page__card-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.users-page__card-head span {
|
||||
font-size: 14px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.users-page__roles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.users-page__role-item {
|
||||
border: 1px solid rgba(127, 86, 217, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(127, 86, 217, 0.04);
|
||||
}
|
||||
|
||||
.users-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.users-page__stat {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(127, 86, 217, 0.08);
|
||||
background: rgba(127, 86, 217, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.users-page__stat-label {
|
||||
font-size: 13px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.users-page__stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.users-page__stat-caption {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.users-page__cta {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.users-page__roadmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.users-page__roadmap-list {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.users-page__roadmap-title {
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.users-page__roadmap-description {
|
||||
margin: 4px 0 0;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
body.body--dark .users-page__card-head h3,
|
||||
body.body--dark .users-page__stat-value,
|
||||
body.body--dark .users-page__roadmap-title {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
body.body--dark .users-page__card-head span,
|
||||
body.body--dark .users-page__stat-label,
|
||||
body.body--dark .users-page__stat-caption,
|
||||
body.body--dark .users-page__roadmap-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.body--dark .users-page__role-item {
|
||||
background: rgba(244, 235, 255, 0.1);
|
||||
border-color: rgba(244, 235, 255, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.users-page__card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.users-page__cta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +1,123 @@
|
||||
<template>
|
||||
<div class="user-list q-pa-md">
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="q-pa-sm q-mb-lg"
|
||||
:class="{
|
||||
'bg-grey-2': !$q.dark.isActive,
|
||||
}"
|
||||
<div class="videos-page">
|
||||
<section class="app-page__header">
|
||||
<div>
|
||||
<h2 class="app-page__title">Biblioteca de vídeos</h2>
|
||||
<p class="app-page__subtitle">
|
||||
Filtre os vídeos processados pela IA, acompanhe o status dos recortes e inicie novas captações
|
||||
em poucos cliques.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-actions-bar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="sym_o_refresh"
|
||||
label="Atualizar"
|
||||
@click="handleSearch(pagination)"
|
||||
/>
|
||||
<Button icon="sym_o_add" label="Novo vídeo" @click="handleAddVideo" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<q-card flat bordered class="videos-page__filters app-card">
|
||||
<div class="videos-page__filters-head">
|
||||
<div>
|
||||
<h3>Filtrar resultados</h3>
|
||||
<span>Refine a busca por título, identificador ou situação.</span>
|
||||
</div>
|
||||
|
||||
<div class="videos-page__filters-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_close"
|
||||
label="Limpar filtros"
|
||||
:disabled="!hasFilters"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="videos-page__filters-grid">
|
||||
<TextField
|
||||
v-model="filters.title"
|
||||
label="Título"
|
||||
placeholder="Ex.: Cortes da live com convidados"
|
||||
>
|
||||
<div class="row q-pa-sm q-gutter-md">
|
||||
<div class="col-3">
|
||||
<TextField label="Título" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<TextField label="Video ID" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<template #prepend>
|
||||
<q-icon name="sym_o_title" color="primary" />
|
||||
</template>
|
||||
</TextField>
|
||||
|
||||
<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="select"
|
||||
v-model="filters.situations"
|
||||
label="Situação"
|
||||
clearable
|
||||
:options="situations"
|
||||
:loading="situationsLoading"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-6">
|
||||
<div class="videos-page__filters-footer">
|
||||
<Button
|
||||
label="Buscar"
|
||||
label="Buscar vídeos"
|
||||
icon="sym_o_search"
|
||||
:loading="loading"
|
||||
@click="handleSearch(pagination)"
|
||||
fullWidth
|
||||
:disabled="loading"
|
||||
@click="handleSearch({ ...pagination, page: 1 })"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<Button label="Teste" @click="handleTeste()" fullWidth />
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<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"
|
||||
:rows="rows"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
key="table-videos"
|
||||
@update:pagination="updatePagination"
|
||||
>
|
||||
<template #no-data>
|
||||
<div
|
||||
class="full-width row flex-center q-gutter-sm"
|
||||
style="font-size: 1.3em"
|
||||
>
|
||||
<span> Não há vídeos </span>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="sym_o_download"
|
||||
label="Exportar CSV"
|
||||
@click="handleExport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #empty-message>
|
||||
Nenhum vídeo encontrado para os filtros selecionados.
|
||||
</template>
|
||||
|
||||
<template #body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<div class="videos-page__cell">
|
||||
<div class="videos-page__cell-label">{{ col.label }}</div>
|
||||
<div class="videos-page__cell-value">{{ col.value }}</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<q-page-sticky position="bottom-right" :offset="[20, 70]">
|
||||
<q-btn fab icon="add" color="accent" @click="handleAddVideo" />
|
||||
</q-page-sticky>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -84,13 +139,13 @@ const columns = [
|
||||
{
|
||||
name: "title",
|
||||
label: "Título",
|
||||
align: "center",
|
||||
align: "left",
|
||||
field: "title",
|
||||
},
|
||||
{
|
||||
name: "clips_quantity",
|
||||
label: "Clipes",
|
||||
align: "left",
|
||||
align: "center",
|
||||
field: "clips_quantity",
|
||||
},
|
||||
{
|
||||
@@ -106,7 +161,7 @@ const columns = [
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "UserList",
|
||||
name: "VideosList",
|
||||
components: {
|
||||
Button,
|
||||
Table,
|
||||
@@ -122,27 +177,54 @@ export default {
|
||||
page: 1,
|
||||
direction: "desc",
|
||||
perPage: 10,
|
||||
total: 10,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
},
|
||||
situations: [],
|
||||
situationsLoading: false,
|
||||
select: [],
|
||||
filters: {
|
||||
title: "",
|
||||
videoId: "",
|
||||
situations: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getSituation();
|
||||
computed: {
|
||||
hasFilters() {
|
||||
return (
|
||||
!!this.filters.title ||
|
||||
!!this.filters.videoId ||
|
||||
(Array.isArray(this.filters.situations) && this.filters.situations.length > 0)
|
||||
);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.initializePage();
|
||||
},
|
||||
methods: {
|
||||
async initializePage() {
|
||||
await this.getSituation();
|
||||
await this.handleSearch(this.pagination);
|
||||
},
|
||||
async handleSearch(pagination) {
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
const baseParams = {
|
||||
situation: this.select,
|
||||
};
|
||||
const baseParams = {};
|
||||
|
||||
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", {
|
||||
params: {
|
||||
@@ -182,31 +264,138 @@ export default {
|
||||
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() {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
margin: 0 auto;
|
||||
<style scoped lang="scss">
|
||||
.videos-page {
|
||||
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>
|
||||
|
||||
@@ -1,183 +1,244 @@
|
||||
<template>
|
||||
<div class="user-list q-pa-md">
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="q-pa-sm q-mb-lg"
|
||||
:class="{
|
||||
'bg-grey-2': !$q.dark.isActive,
|
||||
}"
|
||||
>
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-12">
|
||||
<TextField label="URL do Vídeo" v-model="url" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-6">
|
||||
<Button
|
||||
label="Buscar Informações"
|
||||
:disabled="!url || loading"
|
||||
@click="handleSearch"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="q-pa-sm q-mb-lg"
|
||||
:class="{
|
||||
'bg-grey-2': !$q.dark.isActive,
|
||||
}"
|
||||
>
|
||||
<div v-if="loading" class="user-list q-pa-md flex justify-center">
|
||||
<q-spinner color="primary" size="3em" :thickness="10" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="Object.keys(video).length" class="user-list q-pa-md">
|
||||
<div class="row q-pa-sm">
|
||||
<span class="text-center text-h6">Vídeo</span>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-12">
|
||||
<DisplayValue label="Título" :value="video.title" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-12">
|
||||
<DisplayValue label="Descrição" :value="video.description" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-4">
|
||||
<DisplayValue label="Video ID" :value="video.id" />
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue label="Duração" :value="getDuration()" />
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue label="Data de Postagem" :value="getDateVideo()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-4">
|
||||
<DisplayValue
|
||||
label="Visualizações"
|
||||
:value="convertoToNumberFormat(video.view_count)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue
|
||||
label="Likes"
|
||||
:value="convertoToNumberFormat(video.like_count)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue
|
||||
label="Comentários"
|
||||
:value="convertoToNumberFormat(video.comment_count)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-6">
|
||||
<DisplayValue
|
||||
label="Categorias"
|
||||
:value="video.categories.join(', ')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 q-mt-md">
|
||||
<div style="color: #999; font-weight: bold">Tags</div>
|
||||
|
||||
<div class="row q-mt-sm">
|
||||
<div v-for="tag in video.tags" :key="tag">
|
||||
<q-chip color="primary">{{ tag }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-mt-md" />
|
||||
|
||||
<div class="row q-mt-md q-pa-sm">
|
||||
<span class="text-center text-h6">Canal</span>
|
||||
</div>
|
||||
|
||||
<div class="row q-pa-sm">
|
||||
<div class="col-4">
|
||||
<q-tooltip :offset="[-200, 0]">
|
||||
Clique para abrir no youtube
|
||||
</q-tooltip>
|
||||
<a :href="video.channel_url" target="_blank">
|
||||
<DisplayValue label="Canal" :value="video.channel" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue
|
||||
label="Inscritos"
|
||||
:value="convertoToNumberFormat(video.channel_follower_count)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<DisplayValue label="Identificador" :value="video.channel_id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator v-if="video.thumbnail" class="q-mt-md" />
|
||||
|
||||
<div v-if="video.thumbnail" class="row q-mt-md q-pa-sm">
|
||||
<span class="text-center text-h6">Thumbnail</span>
|
||||
</div>
|
||||
|
||||
<div v-if="video.thumbnail" class="row q-pa-sm">
|
||||
<img
|
||||
:src="video.thumbnail"
|
||||
alt="Thumbnail"
|
||||
height="300"
|
||||
width="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="user-list q-pa-md">
|
||||
<p class="text-center">
|
||||
Cole uma URL e faça a busca dos dados do vídeo
|
||||
<div class="video-create">
|
||||
<section class="app-page__header">
|
||||
<div>
|
||||
<h2 class="app-page__title">Cadastrar novo vídeo</h2>
|
||||
<p class="app-page__subtitle">
|
||||
Cole a URL do YouTube para que a IA analise o conteúdo, identifique os
|
||||
picos de atenção e gere sugestões de clipes automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card flat bordered>
|
||||
<div class="row justify-between q-pa-sm">
|
||||
<div class="app-actions-bar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_lightbulb"
|
||||
label="Guia de cortes rápidos"
|
||||
@click="handleGuide"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="video-create__grid">
|
||||
<q-card flat bordered class="app-card video-create__card">
|
||||
<header class="video-create__card-head">
|
||||
<div>
|
||||
<Button label="Voltar" color="negative" @click="handleCancel" />
|
||||
<h3>URL do vídeo</h3>
|
||||
<p>
|
||||
Utilize vídeos públicos ou não listados. Links privados não são
|
||||
suportados.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<TextField
|
||||
v-model="url"
|
||||
label="Link do YouTube"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
:disabled="loading"
|
||||
required
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="sym_o_link" color="primary" />
|
||||
</template>
|
||||
<template #append>
|
||||
<q-btn
|
||||
v-if="url"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="sym_o_close"
|
||||
color="primary"
|
||||
@click="url = ''"
|
||||
/>
|
||||
</template>
|
||||
</TextField>
|
||||
|
||||
<div class="video-create__actions">
|
||||
<Button
|
||||
label="Buscar informações"
|
||||
icon="sym_o_search"
|
||||
:loading="loading"
|
||||
:disabled="!url || loading"
|
||||
@click="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul class="video-create__hints">
|
||||
<li>Informe apenas o link completo do vídeo no YouTube.</li>
|
||||
<li>
|
||||
Suporte a vídeos até 4 horas e canais com integração liberada.
|
||||
</li>
|
||||
<li>Após a busca, confirme os dados e envie para processamento.</li>
|
||||
</ul>
|
||||
</q-card>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<q-card
|
||||
v-if="loading || videoLoaded"
|
||||
flat
|
||||
bordered
|
||||
class="video-create__preview-card"
|
||||
>
|
||||
<div v-if="loading" class="video-create__preview-loading">
|
||||
<q-skeleton type="rect" class="video-create__thumbnail-skeleton" />
|
||||
<q-skeleton type="text" width="80%" />
|
||||
<q-skeleton type="text" width="60%" />
|
||||
</div>
|
||||
|
||||
<div v-else class="video-create__preview">
|
||||
<div class="video-create__thumbnail">
|
||||
<img :src="videoThumbnail" :alt="video.title" />
|
||||
<q-badge color="primary" class="video-create__badge">{{
|
||||
formattedDuration
|
||||
}}</q-badge>
|
||||
</div>
|
||||
|
||||
<div class="video-create__preview-body">
|
||||
<h4>{{ video.title }}</h4>
|
||||
<p>{{ video.description }}</p>
|
||||
<div class="video-create__preview-actions">
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
@click="handleSave"
|
||||
:loading="saveLoading"
|
||||
:disabled="!video.id"
|
||||
variant="ghost"
|
||||
icon="sym_o_content_copy"
|
||||
label="Copiar ID do vídeo"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
video.id,
|
||||
'Video ID copiado para a área de transferência.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
text-color="primary"
|
||||
icon="sym_o_open_in_new"
|
||||
label="Abrir no YouTube"
|
||||
@click="openOnYoutube"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div v-if="videoLoaded" class="video-create__details">
|
||||
<q-card flat bordered class="app-card video-create__section">
|
||||
<header class="video-create__section-head">
|
||||
<div>
|
||||
<h3>Insights do vídeo</h3>
|
||||
<span>Resumo dos dados coletados diretamente da plataforma.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="sym_o_download"
|
||||
label="Exportar dados"
|
||||
@click="handleExport"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="video-create__stats">
|
||||
<DisplayValue
|
||||
label="Visualizações"
|
||||
:value="formatNumber(video.view_count)"
|
||||
/>
|
||||
<DisplayValue
|
||||
label="Likes"
|
||||
:value="formatNumber(video.like_count)"
|
||||
/>
|
||||
<DisplayValue
|
||||
label="Comentários"
|
||||
:value="formatNumber(video.comment_count)"
|
||||
/>
|
||||
<DisplayValue label="Postado em" :value="formattedDate" />
|
||||
</div>
|
||||
|
||||
<div class="video-create__meta">
|
||||
<DisplayValue
|
||||
label="Categorias"
|
||||
:value="
|
||||
video.categories?.length
|
||||
? video.categories.join(', ')
|
||||
: 'Não informado'
|
||||
"
|
||||
/>
|
||||
<DisplayValue
|
||||
label="Tags"
|
||||
:value="
|
||||
video.tags?.length
|
||||
? `${video.tags.length} tags detectadas`
|
||||
: 'Nenhuma tag localizada'
|
||||
"
|
||||
helper="Os melhores recortes consideram tags com termos fortes."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="video.tags?.length" class="video-create__tags">
|
||||
<q-chip
|
||||
v-for="tag in video.tags"
|
||||
:key="tag"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ tag }}
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card flat bordered class="app-card video-create__section">
|
||||
<header class="video-create__section-head">
|
||||
<div>
|
||||
<h3>Canal</h3>
|
||||
<span
|
||||
>Entenda a audiência do canal para calibrar os próximos
|
||||
cortes.</span
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="video-create__meta">
|
||||
<DisplayValue label="Nome" :value="video.channel" />
|
||||
<DisplayValue
|
||||
label="Inscritos"
|
||||
:value="
|
||||
video.channel_follower_count
|
||||
? formatNumber(video.channel_follower_count)
|
||||
: '—'
|
||||
"
|
||||
/>
|
||||
<DisplayValue
|
||||
label="Uploader"
|
||||
:value="video.uploader || video.channel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="video-create__actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
icon="sym_o_notifications_active"
|
||||
label="Ativar alertas do canal"
|
||||
@click="handleChannelAlerts"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Enviar para processamento"
|
||||
icon="sym_o_auto_awesome"
|
||||
:disabled="!video.id"
|
||||
:loading="saveLoading"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -194,40 +255,6 @@ import { getErrorMessage } from "@utils/axios";
|
||||
|
||||
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 {
|
||||
name: "NewVideo",
|
||||
components: {
|
||||
@@ -239,19 +266,52 @@ export default {
|
||||
return {
|
||||
url: "",
|
||||
loading: false,
|
||||
saveLoading: false,
|
||||
video: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
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: {
|
||||
async handleSearch() {
|
||||
if (!this.url) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.video = {};
|
||||
|
||||
const { data } = await API.get("/videos/search", {
|
||||
params: {
|
||||
url: this.url,
|
||||
url: this.url.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -259,40 +319,324 @@ export default {
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: "negative",
|
||||
message: getErrorMessage(error, "Erro ao buscar vídeos"),
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Erro ao buscar informações do vídeo"
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
getDateVideo() {
|
||||
const duration = dayjs(this.video.timestamp * 1000);
|
||||
formatNumber(number) {
|
||||
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", {
|
||||
maximumSignificantDigits: 3,
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
});
|
||||
|
||||
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;
|
||||
// Chamada de criação ainda não implementada.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
this.$q.notify({
|
||||
type: "positive",
|
||||
message:
|
||||
"Vídeo enviado para processamento. Você será notificado quando os cortes estiverem prontos!",
|
||||
});
|
||||
} 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>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
margin: 0 auto;
|
||||
<style scoped lang="scss">
|
||||
.video-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(32px, 6vw, 48px);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
.video-create__grid {
|
||||
display: grid;
|
||||
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;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
139
src/styles/global.scss
Normal file
139
src/styles/global.scss
Normal file
@@ -0,0 +1,139 @@
|
||||
:root {
|
||||
--app-border-radius: 16px;
|
||||
--app-shadow-sm: 0 8px 18px rgba(16, 24, 40, 0.05);
|
||||
--app-shadow-md: 0 12px 32px rgba(105, 65, 198, 0.12);
|
||||
--app-gradient: linear-gradient(135deg, rgba(127, 86, 217, 0.12), rgba(83, 56, 158, 0.08));
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Roboto", "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f6fa;
|
||||
color: #101828;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
body.body--dark {
|
||||
background: #0b1120;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.q-layout__section--marginal {
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.body--dark .q-layout__section--marginal {
|
||||
background: rgba(16, 24, 40, 0.85);
|
||||
}
|
||||
|
||||
.q-header, .q-footer {
|
||||
border-color: transparent;
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
}
|
||||
|
||||
.q-card {
|
||||
border-radius: var(--app-border-radius);
|
||||
border: 1px solid rgba(127, 86, 217, 0.12);
|
||||
box-shadow: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.q-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--app-shadow-sm);
|
||||
}
|
||||
|
||||
body.body--dark .q-card {
|
||||
background: rgba(16, 24, 40, 0.92);
|
||||
border: 1px solid rgba(244, 235, 255, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
min-height: 100%;
|
||||
padding: 32px clamp(16px, 4vw, 64px);
|
||||
background: linear-gradient(180deg, rgba(127, 86, 217, 0.12) 0%, rgba(255, 255, 255, 0) 40%);
|
||||
}
|
||||
|
||||
body.body--dark .app-page {
|
||||
background: linear-gradient(180deg, rgba(83, 56, 158, 0.3) 0%, rgba(11, 17, 32, 0) 50%);
|
||||
}
|
||||
|
||||
.app-page__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.app-page__title {
|
||||
font-size: clamp(28px, 4vw, 36px);
|
||||
font-weight: 700;
|
||||
color: #53389e;
|
||||
}
|
||||
|
||||
body.body--dark .app-page__title {
|
||||
color: #f4ebff;
|
||||
}
|
||||
|
||||
.app-page__subtitle {
|
||||
max-width: 680px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
body.body--dark .app-page__subtitle {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.app-actions-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
body.body--dark .q-page-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body:not(.body--dark) .q-page-container {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.q-table__top {
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.q-table__bottom {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.q-table__grid-content {
|
||||
border-radius: var(--app-border-radius);
|
||||
}
|
||||
|
||||
.q-pagination {
|
||||
background: rgba(127, 86, 217, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
body.body--dark .q-pagination {
|
||||
background: rgba(83, 56, 158, 0.4);
|
||||
}
|
||||
|
||||
body.body--dark .q-field--filled .q-field__control {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.q-notification {
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--app-shadow-md);
|
||||
}
|
||||
164
src/utils/keycloak.js
Normal file
164
src/utils/keycloak.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import ROLES, { ROLE_ALIASES, ROLE_INHERITANCE } from "@/auth/roles";
|
||||
|
||||
const decodeBase64 = (value) => {
|
||||
if (!value) return "";
|
||||
|
||||
let normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = normalized.length % 4;
|
||||
|
||||
if (pad) {
|
||||
normalized = normalized.padEnd(normalized.length + (4 - pad), "=");
|
||||
}
|
||||
|
||||
if (typeof globalThis.atob === "function") {
|
||||
return globalThis.atob(normalized);
|
||||
}
|
||||
|
||||
if (typeof globalThis.Buffer !== "undefined") {
|
||||
return globalThis.Buffer.from(normalized, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
throw new Error("Environment does not support base64 decoding");
|
||||
};
|
||||
|
||||
const decodeTokenPayload = (token) => {
|
||||
if (!token || typeof token !== "string") return null;
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64(parts[1]));
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error("Failed to decode token payload", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const collectRoles = (payload) => {
|
||||
if (!payload) return [];
|
||||
|
||||
const roles = new Set();
|
||||
|
||||
if (Array.isArray(payload.realm_access?.roles)) {
|
||||
payload.realm_access.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
|
||||
const resourceAccess = payload.resource_access || {};
|
||||
Object.values(resourceAccess).forEach((resource) => {
|
||||
if (Array.isArray(resource?.roles)) {
|
||||
resource.roles.forEach((role) => roles.add(role));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(roles);
|
||||
};
|
||||
|
||||
const aliasEntries = Object.entries(ROLE_ALIASES).map(([canonical, aliases]) => ({
|
||||
canonical,
|
||||
normalized: [...new Set([canonical, ...(aliases || [])])]
|
||||
.filter(Boolean)
|
||||
.map((alias) => alias.toString().trim().toUpperCase()),
|
||||
}));
|
||||
|
||||
const escapeRegex = (value) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const matchesAlias = (role, alias) => {
|
||||
if (role === alias) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const escaped = escapeRegex(alias);
|
||||
const boundaryRegex = new RegExp(`(^|[\\s_:\\-])${escaped}($|[\\s_:\\-])`);
|
||||
|
||||
return boundaryRegex.test(role);
|
||||
};
|
||||
|
||||
const applyInheritance = (roleSet) => {
|
||||
const augmented = new Set(roleSet);
|
||||
|
||||
Object.entries(ROLE_INHERITANCE).forEach(([parent, children]) => {
|
||||
if (augmented.has(parent)) {
|
||||
children.forEach((child) => augmented.add(child));
|
||||
}
|
||||
});
|
||||
|
||||
return augmented;
|
||||
};
|
||||
|
||||
export const mapRolesToAppRoles = (rawRoles = [], { fallbackToUser = true } = {}) => {
|
||||
const normalizedRoles = (Array.isArray(rawRoles) ? rawRoles : [])
|
||||
.filter(Boolean)
|
||||
.map((role) => role.toString().trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalizedUpper = normalizedRoles.map((role) => role.toUpperCase());
|
||||
const result = new Set();
|
||||
|
||||
aliasEntries.forEach(({ canonical, normalized }) => {
|
||||
const match = normalizedUpper.some((role) =>
|
||||
normalized.some((alias) => matchesAlias(role, alias))
|
||||
);
|
||||
|
||||
if (match) {
|
||||
result.add(canonical);
|
||||
}
|
||||
});
|
||||
|
||||
const inherited = applyInheritance(result);
|
||||
|
||||
if (
|
||||
inherited.size === 0 &&
|
||||
fallbackToUser &&
|
||||
normalizedUpper.length > 0
|
||||
) {
|
||||
inherited.add(ROLES.USER);
|
||||
}
|
||||
|
||||
return Array.from(inherited);
|
||||
};
|
||||
|
||||
export const extractRolesFromToken = (token) => {
|
||||
const payload = decodeTokenPayload(token);
|
||||
const rawRoles = collectRoles(payload);
|
||||
|
||||
return mapRolesToAppRoles(rawRoles);
|
||||
};
|
||||
|
||||
export const buildUserProfileFromToken = (token) => {
|
||||
const payload = decodeTokenPayload(token);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawRoles = collectRoles(payload);
|
||||
const appRoles = mapRolesToAppRoles(rawRoles);
|
||||
const name = payload.name || payload.preferred_username || "";
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
name,
|
||||
email: payload.email || "",
|
||||
username: payload.preferred_username || payload.email || name,
|
||||
firstName: payload.given_name || "",
|
||||
lastName: payload.family_name || "",
|
||||
roles: appRoles,
|
||||
rawRoles,
|
||||
raw: payload,
|
||||
};
|
||||
};
|
||||
|
||||
export const extractUserInitials = (name = "") => {
|
||||
const sanitized = name.trim();
|
||||
if (!sanitized) return "";
|
||||
|
||||
const parts = sanitized.split(/\s+/).slice(0, 2);
|
||||
return parts
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
};
|
||||
|
||||
export const decodeToken = (token) => decodeTokenPayload(token);
|
||||
@@ -6,6 +6,16 @@ import { fileURLToPath } from "node:url";
|
||||
import { quasar, transformAssetUrls } from "@quasar/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src/"),
|
||||
|
||||
Reference in New Issue
Block a user