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

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_ENV=development

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_ENV=production

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
yarn.lock
package-lock.json
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# Vue 3 + Vite ❤
Este projeto é uma base para aplicações front-end Vue 3, utilizando Vite para um desenvolvimento rápido e eficiente. Abaixo estão as bibliotecas mais importantes utilizadas neste projeto:
## Dependências Principais
- **Vue**: Um framework progressivo para a construção de interfaces de usuário. Ele é projetado para ser incrementalmente adotável, o que significa que você pode usá-lo para desenvolver desde pequenas partes de uma aplicação até grandes aplicações de página única.
- **Quasar**: Um framework Vue.js de alto desempenho que permite construir websites responsivos, PWAs (Progressive Web Apps), aplicações SSR (Server-Side Rendered), SPAs (Single-Page Applications), aplicações móveis e aplicações desktop a partir de uma única base de código.
- **Vue Router**: O roteador oficial para Vue.js. Ele se integra profundamente com o Vue.js para facilitar a construção de Single Page Applications robustas com roteamento dinâmico de componentes.
- **Vuex**: A biblioteca oficial de gerenciamento de estado para aplicações Vue.js. Ele serve como um armazenamento centralizado para todos os componentes em uma aplicação, com regras que garantem que o estado só possa ser modificado de forma previsível.
- **Axios**: Um cliente HTTP baseado em Promises para o navegador e Node.js. É amplamente utilizado para fazer requisições HTTP (GET, POST, PUT, DELETE, etc.) a APIs externas ou internas.
- **Day.js**: Uma biblioteca JavaScript minimalista que analisa, valida, manipula e exibe datas e horas para navegadores modernos. Possui uma API amplamente compatível com Moment.js, mas com um tamanho de arquivo muito menor.
- **Lodash**: Uma biblioteca utilitária JavaScript moderna que oferece modularidade, desempenho e recursos extras. Ela fornece funções para manipulação de arrays, objetos, strings, números e muito mais, tornando o código mais conciso e legível.
- **@quasar/extras**: Fornece ativos extras para o Quasar, como ícones (Material Icons, Font Awesome, etc.) e fontes, que são essenciais para a estilização e a interface do usuário das aplicações Quasar.

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta author="Leonardo Mortari" />
<meta description="This project is a front-end base project with Vue app" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Vue Base JS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "vue-frontend-base-js",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@quasar/extras": "1.17.0",
"@vee-validate/zod": "4.15.1",
"axios": "1.11.0",
"dayjs": "1.11.13",
"js-cookie": "3.0.5",
"lodash": "4.17.21",
"quasar": "2.18.2",
"vee-validate": "4.15.1",
"vue": "3.5.18",
"vue-router": "4.5.1",
"vuex": "4.1.0",
"zod": "4.1.5"
},
"devDependencies": {
"@quasar/vite-plugin": "1.10.0",
"@types/js-cookie": "3.0.6",
"@types/lodash": "4.17.20",
"@vitejs/plugin-vue": "6.0.1",
"sass-embedded": "1.90.0",
"vite": "7.1.1"
}
}

1
public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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>

28
vite.config.js Normal file
View File

@@ -0,0 +1,28 @@
import vue from "@vitejs/plugin-vue";
import path from "node:path";
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import { quasar, transformAssetUrls } from "@quasar/vite-plugin";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src/"),
"@assets": path.resolve(__dirname, "src/assets/"),
"@components": path.resolve(__dirname, "src/components/"),
"@utils": path.resolve(__dirname, "src/utils/"),
},
extensions: [".js", ".vue", ".json"],
},
plugins: [
vue({
template: { transformAssetUrls },
}),
quasar({
sassVariables: fileURLToPath(
new URL("./src/quasar-variables.sass", import.meta.url)
),
}),
],
});