Ajusta layout

This commit is contained in:
LeoMortari
2025-11-01 21:35:59 -03:00
parent 9c4600c64e
commit 9fafc4e6f4
18 changed files with 3676 additions and 606 deletions

View File

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

View File

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

View File

@@ -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,
},
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,6 @@ API.interceptors.response.use(
window.location.href = "/login";
}
return Promise.reject(error);
throw error;
}
);

View File

@@ -10,6 +10,7 @@ import "@quasar/extras/material-symbols-outlined/material-symbols-outlined.css";
import "@quasar/extras/mdi-v7/mdi-v7.css";
import "quasar/src/css/index.sass";
import "./styles/global.scss";
import App from "./App.vue";

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,139 @@
:root {
--app-border-radius: 16px;
--app-shadow-sm: 0 8px 18px rgba(16, 24, 40, 0.05);
--app-shadow-md: 0 12px 32px rgba(105, 65, 198, 0.12);
--app-gradient: linear-gradient(135deg, rgba(127, 86, 217, 0.12), rgba(83, 56, 158, 0.08));
}
body {
font-family: "Roboto", "Helvetica Neue", Arial, sans-serif;
background: #f5f6fa;
color: #101828;
transition: background 0.3s ease;
}
body.body--dark {
background: #0b1120;
color: rgba(255, 255, 255, 0.92);
}
.q-layout__section--marginal {
backdrop-filter: blur(18px);
background: rgba(255, 255, 255, 0.88);
}
body.body--dark .q-layout__section--marginal {
background: rgba(16, 24, 40, 0.85);
}
.q-header, .q-footer {
border-color: transparent;
box-shadow: var(--app-shadow-sm);
}
.q-card {
border-radius: var(--app-border-radius);
border: 1px solid rgba(127, 86, 217, 0.12);
box-shadow: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.q-card:hover {
transform: translateY(-2px);
box-shadow: var(--app-shadow-sm);
}
body.body--dark .q-card {
background: rgba(16, 24, 40, 0.92);
border: 1px solid rgba(244, 235, 255, 0.1);
box-shadow: none;
}
.app-page {
min-height: 100%;
padding: 32px clamp(16px, 4vw, 64px);
background: linear-gradient(180deg, rgba(127, 86, 217, 0.12) 0%, rgba(255, 255, 255, 0) 40%);
}
body.body--dark .app-page {
background: linear-gradient(180deg, rgba(83, 56, 158, 0.3) 0%, rgba(11, 17, 32, 0) 50%);
}
.app-page__header {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.app-page__title {
font-size: clamp(28px, 4vw, 36px);
font-weight: 700;
color: #53389e;
}
body.body--dark .app-page__title {
color: #f4ebff;
}
.app-page__subtitle {
max-width: 680px;
color: #475467;
}
body.body--dark .app-page__subtitle {
color: rgba(255, 255, 255, 0.74);
}
.app-actions-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.app-card {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
body.body--dark .q-page-container {
background: transparent;
}
body:not(.body--dark) .q-page-container {
background: #f5f6fa;
}
.q-table__top {
padding: 0 0 16px;
}
.q-table__bottom {
padding: 16px 0 0;
}
.q-table__grid-content {
border-radius: var(--app-border-radius);
}
.q-pagination {
background: rgba(127, 86, 217, 0.06);
border-radius: 999px;
padding: 6px 12px;
}
body.body--dark .q-pagination {
background: rgba(83, 56, 158, 0.4);
}
body.body--dark .q-field--filled .q-field__control {
background: rgba(255, 255, 255, 0.08);
}
.q-notification {
border-radius: 14px;
box-shadow: var(--app-shadow-md);
}

164
src/utils/keycloak.js Normal file
View File

@@ -0,0 +1,164 @@
import ROLES, { ROLE_ALIASES, ROLE_INHERITANCE } from "@/auth/roles";
const decodeBase64 = (value) => {
if (!value) return "";
let normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const pad = normalized.length % 4;
if (pad) {
normalized = normalized.padEnd(normalized.length + (4 - pad), "=");
}
if (typeof globalThis.atob === "function") {
return globalThis.atob(normalized);
}
if (typeof globalThis.Buffer !== "undefined") {
return globalThis.Buffer.from(normalized, "base64").toString("utf-8");
}
throw new Error("Environment does not support base64 decoding");
};
const decodeTokenPayload = (token) => {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const payload = JSON.parse(decodeBase64(parts[1]));
return payload;
} catch (error) {
console.error("Failed to decode token payload", error);
return null;
}
};
const collectRoles = (payload) => {
if (!payload) return [];
const roles = new Set();
if (Array.isArray(payload.realm_access?.roles)) {
payload.realm_access.roles.forEach((role) => roles.add(role));
}
const resourceAccess = payload.resource_access || {};
Object.values(resourceAccess).forEach((resource) => {
if (Array.isArray(resource?.roles)) {
resource.roles.forEach((role) => roles.add(role));
}
});
return Array.from(roles);
};
const aliasEntries = Object.entries(ROLE_ALIASES).map(([canonical, aliases]) => ({
canonical,
normalized: [...new Set([canonical, ...(aliases || [])])]
.filter(Boolean)
.map((alias) => alias.toString().trim().toUpperCase()),
}));
const escapeRegex = (value) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matchesAlias = (role, alias) => {
if (role === alias) {
return true;
}
const escaped = escapeRegex(alias);
const boundaryRegex = new RegExp(`(^|[\\s_:\\-])${escaped}($|[\\s_:\\-])`);
return boundaryRegex.test(role);
};
const applyInheritance = (roleSet) => {
const augmented = new Set(roleSet);
Object.entries(ROLE_INHERITANCE).forEach(([parent, children]) => {
if (augmented.has(parent)) {
children.forEach((child) => augmented.add(child));
}
});
return augmented;
};
export const mapRolesToAppRoles = (rawRoles = [], { fallbackToUser = true } = {}) => {
const normalizedRoles = (Array.isArray(rawRoles) ? rawRoles : [])
.filter(Boolean)
.map((role) => role.toString().trim())
.filter(Boolean);
const normalizedUpper = normalizedRoles.map((role) => role.toUpperCase());
const result = new Set();
aliasEntries.forEach(({ canonical, normalized }) => {
const match = normalizedUpper.some((role) =>
normalized.some((alias) => matchesAlias(role, alias))
);
if (match) {
result.add(canonical);
}
});
const inherited = applyInheritance(result);
if (
inherited.size === 0 &&
fallbackToUser &&
normalizedUpper.length > 0
) {
inherited.add(ROLES.USER);
}
return Array.from(inherited);
};
export const extractRolesFromToken = (token) => {
const payload = decodeTokenPayload(token);
const rawRoles = collectRoles(payload);
return mapRolesToAppRoles(rawRoles);
};
export const buildUserProfileFromToken = (token) => {
const payload = decodeTokenPayload(token);
if (!payload) {
return null;
}
const rawRoles = collectRoles(payload);
const appRoles = mapRolesToAppRoles(rawRoles);
const name = payload.name || payload.preferred_username || "";
return {
id: payload.sub,
name,
email: payload.email || "",
username: payload.preferred_username || payload.email || name,
firstName: payload.given_name || "",
lastName: payload.family_name || "",
roles: appRoles,
rawRoles,
raw: payload,
};
};
export const extractUserInitials = (name = "") => {
const sanitized = name.trim();
if (!sanitized) return "";
const parts = sanitized.split(/\s+/).slice(0, 2);
return parts
.map((part) => part.charAt(0).toUpperCase())
.join("");
};
export const decodeToken = (token) => decodeTokenPayload(token);

View File

@@ -6,6 +6,16 @@ import { fileURLToPath } from "node:url";
import { quasar, transformAssetUrls } from "@quasar/vite-plugin";
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/"),