init repo

This commit is contained in:
LeoMortari
2025-08-28 20:22:13 -03:00
commit 54fc9f400b
22 changed files with 913 additions and 0 deletions

78
src/App.vue Normal file
View File

@@ -0,0 +1,78 @@
<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-toolbar>
</q-header>
<q-drawer v-if="!isLoginPage" show-if-above v-model="leftDrawerOpen" side="left" bordered>
<div class="row q-pa-md items-center">
<div class="col q-ml-xs title">
<span>Clipper AI</span>
</div>
</div>
<q-separator />
<q-list>
<q-item v-for="route in routes" clickable v-ripple :to="route.path">
<q-item-section>{{ route.title }}</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script>
import { Dark } from "quasar";
import Button from "@components/Button";
import Toggle from "@components/Toggle";
import { routes } from "./auth/router";
export default {
components: {
Button,
Toggle,
},
data() {
return {
leftDrawerOpen: false,
darkMode: Dark.isActive,
routes,
};
},
methods: {
toggleLeftDrawer() {
this.leftDrawerOpen = !this.leftDrawerOpen;
},
toggleDarkMode() {
Dark.toggle();
},
},
computed: {
currentRouteTitle() {
const route = this.$route;
return route.meta?.title || route.name || 'Clipper AI';
},
isLoginPage() {
return this.$route.path === '/login';
}
},
};
</script>
<style lang="scss" scoped>
.title {
font-size: 24px;
color: $primary;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

3
src/auth/roles.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
VIDEOS_LIST: "role_videos_6550",
};

100
src/auth/router.js Normal file
View File

@@ -0,0 +1,100 @@
import Cookies from "js-cookie";
import { createWebHistory, createRouter } from "vue-router";
import roles from "@/auth/roles";
import Videos from "@/routes/videos";
import Login from "@/routes/auth/Login.vue";
const getUserRoles = () => {
const rolesFromCookie = Cookies.get("user_roles"); // TODO: Tirar as permissões do usuário
return rolesFromCookie ? JSON.parse(rolesFromCookie) : [];
};
export const routes = [
{
path: "/login",
name: "Login",
component: Login,
meta: {
guest: true,
title: "Login",
},
},
{
path: "/videos",
component: Videos,
name: "Videos",
meta: {
requiresAuth: true,
title: "Vídeos",
permissions: [roles.VIDEOS_LIST],
},
},
{
path: "/:pathMatch(.*)*",
redirect: (to) => {
return Cookies.get("token") ? "/videos" : "/login";
},
},
{
path: "/",
redirect: (to) => {
console.log(Cookies);
return Cookies.get("token") ? "/videos" : "/login";
},
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
export const isAuthenticated = () => {
return !!Cookies.get("token");
};
const hasPermission = (requiredPermissions = []) => {
if (!requiredPermissions.length) return true;
const userRoles = getUserRoles();
return requiredPermissions.some((permission) =>
userRoles.includes(permission)
);
};
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = `${to.meta.title} | ClipperIA`;
}
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!isAuthenticated()) {
next({
path: "/login",
query: { redirect: to.fullPath },
});
return;
}
const requiredPermissions = to.meta.permissions || [];
if (requiredPermissions.length > 0 && !hasPermission(requiredPermissions)) {
next({ path: "/unauthorized" });
return;
}
}
if (to.matched.some((record) => record.meta.guest) && isAuthenticated()) {
next({ path: "/" });
return;
}
next();
});

View File

@@ -0,0 +1,50 @@
<template>
<q-btn
:icon="icon"
:label="label"
:loading="loading"
:color="color"
:type="type"
:disable="loading || disabled"
:text-color="textColor"
/>
</template>
<script>
export default {
name: "Button",
props: {
color: {
type: String,
default: "primary",
validator: (value) => ["primary", "secondary"].includes(value),
},
icon: {
type: String,
},
label: {
type: String,
},
loading: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "button",
},
disabled: {
type: Boolean,
default: false,
},
textColor: {
type: String,
default: "white",
},
fullWidth: {
type: Boolean,
default: false,
},
},
};
</script>

View File

@@ -0,0 +1,92 @@
<template>
<q-card class="q-pa-md" flat bordered>
<q-table
:title="title"
:rows="rows"
:columns="columns"
:row-key="rowKey"
:loading="loading"
row-key="name"
flat
bordered
virtual-scroll
hide-pagination
>
<template v-slot:loading>
<q-inner-loading color="primary" />
</template>
<template v-slot:no-data>
<slot name="no-data" />
</template>
</q-table>
<div class="row q-mt-md">
<div class="col-8 flex justify-end">
<q-pagination
v-model="pagination.currentPage"
color="primary"
:max="pagination.totalPages"
size="md"
/>
</div>
<div class="col-4 flex justify-end items-center">
<span class="text-grey-5"
>Mostrando {{ pagination.currentPage }} de
{{ pagination.totalItems }}</span
>
</div>
</div>
</q-card>
</template>
<script>
import has from "lodash/has";
export default {
props: {
columns: {
type: Array,
required: true,
validator: (value) => value.some((item) => has(item, ["name", "label"])),
},
rows: {
type: Array,
required: true,
validator: (value) => value.some((item) => has(item, ["name", "label"])),
},
rowName: {
type: String,
required: false,
default: "name",
},
title: {
type: String,
required: false,
},
loading: {
type: Boolean,
required: false,
default: false,
},
pagination: {
type: Object,
required: false,
default: {
sortBy: "desc",
descending: false,
currentPage: 1,
totalPages: 1,
totalItems: 10,
},
},
},
setup(props) {
return {
columns: props.columns,
rows: props.rows,
};
},
};
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="q-pa-sm">
<div>
<span>{{ label }} {{ required ? "*" : "" }}</span>
</div>
<q-input outlined v-model="text" />
</div>
</template>
<script>
import { ref } from "vue";
export default {
props: {
label: {
type: String,
required: false,
},
required: {
type: Boolean,
required: false,
},
},
setup() {
return {
text: ref(""),
};
},
};
</script>

View File

@@ -0,0 +1,30 @@
<template>
<q-toggle
v-model="value"
v-bind="$attrs"
:color="color"
@update:model-value="toggle"
/>
</template>
<script>
export default {
name: "Toggle",
model: {
prop: "value",
event: "update:model-value",
},
props: {
color: {
type: String,
default: "secondary",
validator: (value) => ["primary", "secondary"].includes(value),
},
},
methods: {
toggle() {
this.$emit("toggle", this.value);
},
},
};
</script>

30
src/main.js Normal file
View File

@@ -0,0 +1,30 @@
import { createApp } from "vue";
import { Quasar } from "quasar";
import quasarLang from "quasar/lang/pt-BR";
import "@quasar/extras/roboto-font/roboto-font.css";
import "@quasar/extras/material-icons/material-icons.css";
import "@quasar/extras/material-icons-outlined/material-icons-outlined.css";
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 App from "./App.vue";
import { router } from "./auth/router";
const app = createApp(App);
app.use(Quasar, {
config: {
dark: "auto",
},
plugins: {},
lang: quasarLang,
});
app.use(router);
app.mount("#app");

11
src/quasar-variables.sass Normal file
View File

@@ -0,0 +1,11 @@
$primary : #8200ff
$secondary : #eabdff
$accent : #8200ff
$dark : #1c1e21
$dark-page : #1c1e21
$success : #31cb00
$error : #ee2e31
$info : #226ce0
$warning : #fcdc4d

166
src/routes/auth/Login.vue Normal file
View File

@@ -0,0 +1,166 @@
<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-scroll-area class="fit">
<div class="q-pa-md">
<q-form @submit.prevent="handleLogin" class="q-mt-lg">
<q-input
v-model="email"
type="email"
label="E-mail"
lazy-rules
:rules="[(val) => !!val || 'Campo obrigatório']"
class="q-mb-md"
dark
filled
>
<template v-slot:prepend>
<q-icon name="mail" />
</template>
</q-input>
<q-input
v-model="password"
:type="showPassword ? 'text' : 'password'"
label="Senha"
lazy-rules
:rules="[(val) => !!val || 'Campo obrigatório']"
class="q-mb-lg"
dark
filled
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<Button
type="submit"
label="Entrar"
color="white"
textColor="primary"
full-width
:loading="loading"
/>
</q-form>
<p v-if="error" class="text-negative q-mt-md">{{ error }}</p>
</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>
</div>
</q-page>
</q-page-container>
</q-layout>
</template>
<script>
import Button from "@components/Button";
export default {
name: "LoginView",
components: {
Button,
},
data() {
return {
leftDrawerOpen: true,
email: "",
password: "",
showPassword: false,
error: "",
loading: false,
};
},
methods: {
handleLogin() {
this.error = "";
if (!this.email || !this.password) {
this.error = "Por favor, preencha todos os campos";
return;
}
this.loading = true;
// setTimeout(() => {
// try {
// localStorage.setItem("auth_token", "dummy_token");
// const redirectPath = this.$route.query.redirect || "/";
// this.$router.push(redirectPath);
// } catch (err) {
// this.error = err.message || "Erro ao fazer login. Tente novamente.";
// } finally {
// this.loading = false;
// }
// }, 1000);
},
},
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);
}
/* Ajustes para responsividade */
@media (max-width: 700px) {
.q-drawer {
width: 100% !important;
}
.q-page-container {
padding-right: 0 !important;
}
}
/* Estilo para o botão de login */
.q-btn--actionable {
transition: transform 0.2s, opacity 0.2s;
}
.q-btn--actionable:active:not(.disabled) {
transform: scale(0.98);
}
/* Melhorias na acessibilidade */
.q-field--filled .q-field__control {
border-radius: 8px;
}
/* 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;
}
</style>

192
src/routes/videos/index.vue Normal file
View File

@@ -0,0 +1,192 @@
<template>
<div class="user-list q-pa-md">
<q-card flat bordered class="q-pa-sm q-mb-lg">
<div class="row">
<div class="col-4">
<TextField label="Nome" required />
</div>
</div>
</q-card>
<Table :columns="columns" :rows="rows" :pagination="pagination">
<template #no-data>
<div
class="full-width row flex-center q-gutter-sm"
style="font-size: 1.3em"
>
<span> Infelizmente, não dados para serem mostrados </span>
</div>
</template>
</Table>
</div>
</template>
<script>
import Table from "@components/Table";
import TextField from "@components/TextField";
const columns = [
{
name: "name",
required: true,
label: "Dessert (100g serving)",
align: "left",
field: (row) => row.name,
format: (val) => `${val}`,
sortable: true,
},
{
name: "calories",
align: "center",
label: "Calories",
field: "calories",
sortable: true,
},
{ name: "fat", label: "Fat (g)", field: "fat", sortable: true },
{ name: "carbs", label: "Carbs (g)", field: "carbs" },
{ name: "protein", label: "Protein (g)", field: "protein" },
{ name: "sodium", label: "Sodium (mg)", field: "sodium" },
{
name: "calcium",
label: "Calcium (%)",
field: "calcium",
sortable: true,
sort: (a, b) => parseInt(a, 10) - parseInt(b, 10),
},
{
name: "iron",
label: "Iron (%)",
field: "iron",
sortable: true,
sort: (a, b) => parseInt(a, 10) - parseInt(b, 10),
},
];
const rows = [
{
name: "Frozen Yogurt",
calories: 159,
fat: 6.0,
carbs: 24,
protein: 4.0,
sodium: 87,
calcium: "14%",
iron: "1%",
},
{
name: "Ice cream sandwich",
calories: 237,
fat: 9.0,
carbs: 37,
protein: 4.3,
sodium: 129,
calcium: "8%",
iron: "1%",
},
{
name: "Eclair",
calories: 262,
fat: 16.0,
carbs: 23,
protein: 6.0,
sodium: 337,
calcium: "6%",
iron: "7%",
},
{
name: "Cupcake",
calories: 305,
fat: 3.7,
carbs: 67,
protein: 4.3,
sodium: 413,
calcium: "3%",
iron: "8%",
},
{
name: "Gingerbread",
calories: 356,
fat: 16.0,
carbs: 49,
protein: 3.9,
sodium: 327,
calcium: "7%",
iron: "16%",
},
{
name: "Jelly bean",
calories: 375,
fat: 0.0,
carbs: 94,
protein: 0.0,
sodium: 50,
calcium: "0%",
iron: "0%",
},
{
name: "Lollipop",
calories: 392,
fat: 0.2,
carbs: 98,
protein: 0,
sodium: 38,
calcium: "0%",
iron: "2%",
},
{
name: "Honeycomb",
calories: 408,
fat: 3.2,
carbs: 87,
protein: 6.5,
sodium: 562,
calcium: "0%",
iron: "45%",
},
{
name: "Donut",
calories: 452,
fat: 25.0,
carbs: 51,
protein: 4.9,
sodium: 326,
calcium: "2%",
iron: "22%",
},
{
name: "KitKat",
calories: 518,
fat: 26.0,
carbs: 65,
protein: 7,
sodium: 54,
calcium: "12%",
iron: "6%",
},
];
export default {
name: "UserList",
components: {
Table,
TextField,
},
data() {
return {
columns,
rows,
pagination: {
currentPage: 1,
totalPages: 1,
totalItems: 10,
},
};
},
};
</script>
<style scoped>
.user-list {
margin: 0 auto;
}
</style>