From abe7d8bbdb93c45655c9aa8ab6f063e3b5433882 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 28 Jul 2026 20:21:53 +0000 Subject: [PATCH] Assets 3D --- .../20260728000000-create-assets-tags-tags.js | 70 +++ frontend/src/components/AsideMenuLayer.tsx | 3 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 6 + frontend/src/pages/asset-studio/index.tsx | 481 ++++++++++++++++++ frontend/src/pages/index.tsx | 284 +++++------ frontend/src/pages/search.tsx | 4 +- 8 files changed, 698 insertions(+), 156 deletions(-) create mode 100644 backend/src/db/migrations/20260728000000-create-assets-tags-tags.js create mode 100644 frontend/src/pages/asset-studio/index.tsx diff --git a/backend/src/db/migrations/20260728000000-create-assets-tags-tags.js b/backend/src/db/migrations/20260728000000-create-assets-tags-tags.js new file mode 100644 index 0000000..89dfc7a --- /dev/null +++ b/backend/src/db/migrations/20260728000000-create-assets-tags-tags.js @@ -0,0 +1,70 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (!tableNames.includes('assetsTagsTags')) { + await queryInterface.createTable( + 'assetsTagsTags', + { + createdAt: { + allowNull: false, + type: Sequelize.DataTypes.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DataTypes.DATE, + }, + assets_tagsId: { + type: Sequelize.DataTypes.UUID, + primaryKey: true, + references: { + model: 'assets', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + tagId: { + type: Sequelize.DataTypes.UUID, + primaryKey: true, + references: { + model: 'tags', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tables = await queryInterface.showAllTables({ transaction }); + const tableNames = tables.map((table) => (typeof table === 'object' ? table.tableName : table)); + + if (tableNames.includes('assetsTagsTags')) { + await queryInterface.dropTable('assetsTagsTags', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index f4a0809..214885c 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 094a153..f3133d7 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/asset-studio', + icon: icon.mdiCubeOutline, + label: 'Asset Forge', + permissions: 'READ_ASSETS', + }, { href: '/users/users-list', diff --git a/frontend/src/pages/asset-studio/index.tsx b/frontend/src/pages/asset-studio/index.tsx new file mode 100644 index 0000000..c4515de --- /dev/null +++ b/frontend/src/pages/asset-studio/index.tsx @@ -0,0 +1,481 @@ +import { + mdiCheckCircleOutline, + mdiChevronRight, + mdiCubeOutline, + mdiFileUploadOutline, + mdiFormatListBulletedSquare, + mdiLightbulbOnOutline, + mdiRocketLaunchOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ChangeEvent, FormEvent, ReactElement, useEffect, useMemo, useState } from 'react'; + +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import FileUploader from '../../components/Uploaders/UploadService'; +import { getPageTitle } from '../../config'; + +const statuses = [ + { value: 'idea', label: 'Ideia' }, + { value: 'in_progress', label: 'Em produção' }, + { value: 'review', label: 'Revisão' }, + { value: 'approved', label: 'Aprovado' }, + { value: 'ready_to_export', label: 'Pronto para exportar' }, +]; + +const stages = [ + { value: 'blockout', label: 'Blockout' }, + { value: 'highpoly', label: 'High Poly' }, + { value: 'lowpoly', label: 'Low Poly' }, + { value: 'uv', label: 'UV' }, + { value: 'texture', label: 'Textura' }, + { value: 'final', label: 'Final' }, +]; + +const formats = ['fbx', 'glb', 'gltf', 'obj', 'blend', 'png', 'tga', 'jpg', 'psd', 'zip', 'other']; + +const initialForm = { + name: '', + source: '', + brief: '', + status: 'idea', + priority: 'medium', + license: 'original', + scale_meters: '1', + version_name: 'Primeira versão', + version_number: '1', + stage: 'blockout', + file_kind: 'model', + format: 'glb', + label: '', +}; + +const badgeStyles = { + idea: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-200', + blocked: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200', + in_progress: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-200', + review: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200', + approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200', + ready_to_export: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-200', + exported: 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-200', + deprecated: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-500/20 dark:text-zinc-200', +}; + +type AssetStatus = keyof typeof badgeStyles; + +type AssetRecord = { + id: string; + name?: string; + slug?: string; + brief?: string; + status?: AssetStatus; + priority?: string; + source?: string; + scale_meters?: string | number; + createdAt?: string; +}; + +type CreatedSummary = { + asset: AssetRecord; + version?: { id: string; version_name?: string; stage?: string }; + assetFile?: { label?: string; format?: string } | null; +}; + +const inputClass = + 'w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm outline-none transition focus:border-cyan-400 focus:ring-4 focus:ring-cyan-100 dark:border-dark-700 dark:bg-dark-900 dark:focus:ring-cyan-900/40'; + +const AssetStudioPage = () => { + const [form, setForm] = useState(initialForm); + const [file, setFile] = useState(null); + const [recentAssets, setRecentAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + const [createdSummary, setCreatedSummary] = useState(null); + const [loadingList, setLoadingList] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const completion = useMemo(() => { + const checks = [form.name, form.brief, form.version_name, form.stage, form.format, file?.name]; + return Math.round((checks.filter(Boolean).length / checks.length) * 100); + }, [file, form.brief, form.format, form.name, form.stage, form.version_name]); + + const fetchRecentAssets = async () => { + setLoadingList(true); + const response = await axios.post('sql', { + sql: 'SELECT id, name, slug, brief, status, priority, source, scale_meters, \"createdAt\" FROM assets WHERE \"deletedAt\" IS NULL ORDER BY \"createdAt\" DESC LIMIT 8', + }); + const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; + setRecentAssets(rows); + setSelectedAsset((current) => current || rows[0] || null); + setLoadingList(false); + }; + + useEffect(() => { + fetchRecentAssets().catch((err) => { + console.error('Asset Studio list failed:', err); + setError('Não foi possível carregar os assets recentes.'); + setLoadingList(false); + }); + }, []); + + const updateField = (event: ChangeEvent) => { + setForm((current) => ({ ...current, [event.target.name]: event.target.value })); + }; + + const slugify = (value: string) => + value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, ''); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(''); + + if (!form.name.trim()) { + setError('Dê um nome para o item 3D antes de criar o asset.'); + return; + } + + setSubmitting(true); + + try { + let uploadedFile = null; + if (file) { + uploadedFile = await FileUploader.upload('asset_files/file', file, { + size: 50 * 1024 * 1024, + formats, + }); + } + + const slug = slugify(`${form.name}-${Date.now()}`); + + await axios.post('assets', { + data: { + name: form.name.trim(), + slug, + brief: form.brief ? `

${form.brief}

` : null, + status: form.status, + priority: form.priority, + license: form.license, + source: form.source || null, + scale_meters: form.scale_meters || null, + }, + }); + + const createdAssetResponse = await axios.post('sql', { + sql: `SELECT id, name, slug, brief, status, priority, source, scale_meters, \"createdAt\" FROM assets WHERE slug = '${slug}' AND \"deletedAt\" IS NULL LIMIT 1`, + }); + const asset = createdAssetResponse.data?.rows?.[0]; + if (!asset?.id) { + throw new Error('Não foi possível localizar o asset recém-criado.'); + } + + await axios.post('asset_versions', { + data: { + asset: asset.id, + version_name: form.version_name || 'Primeira versão', + version_number: Number(form.version_number) || 1, + stage: form.stage, + changelog: `Criado pelo Asset Forge: ${form.brief || 'sem notas iniciais'}`, + is_current: true, + }, + }); + + const versionResponse = await axios.post('sql', { + sql: `SELECT id, version_name, stage FROM asset_versions WHERE \"assetId\" = '${asset.id}' AND \"deletedAt\" IS NULL ORDER BY \"createdAt\" DESC LIMIT 1`, + }); + const assetDetail = asset; + const version = versionResponse.data?.rows?.[0] || null; + + let assetFile = null; + if (uploadedFile && version?.id) { + assetFile = { label: form.label || uploadedFile.name, format: form.format }; + await axios.post('asset_files', { + data: { + asset_version: version.id, + file_kind: form.file_kind, + format: form.format, + label: assetFile.label, + file_size_bytes: uploadedFile.sizeInBytes, + file: [uploadedFile], + }, + }); + } + + setCreatedSummary({ asset: assetDetail, version, assetFile }); + setSelectedAsset(assetDetail); + setForm({ ...initialForm, version_name: 'v1 - gameplay ready draft' }); + setFile(null); + await fetchRecentAssets(); + } catch (err) { + console.error('Asset Studio submit failed:', err); + setError('Não foi possível criar o fluxo do asset. Confira os campos e tente novamente.'); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + {getPageTitle('Asset Forge')} + + + + + + +
+
+
+
+ MVP workflow para artistas 3D +
+

+ Cadastre o item, versione o progresso e anexe o arquivo para o jogo. +

+

+ Um fluxo curto para transformar ideias de props, armas, personagens ou cenários em assets rastreáveis com status, etapa e arquivo fonte/exportado. +

+
+ {[ + ['01', 'Brief do asset'], + ['02', 'Primeira versão'], + ['03', 'Arquivo FBX/GLB/PNG'], + ].map(([step, label]) => ( +
+ {step} +

{label}

+
+ ))} +
+
+
+

Prontidão do cadastro

+
+
+
+

{completion}%

+

Preencha nome, briefing, versão, etapa, formato e arquivo para um pacote inicial completo.

+
+
+
+ + {error && ( +
+ {error} +
+ )} + + {createdSummary && ( +
+
+
+ +
+

Asset criado com sucesso: {createdSummary.asset.name}

+

Versão {createdSummary.version?.version_name || 'v1'} registrada{createdSummary.assetFile ? ` com arquivo ${createdSummary.assetFile.format}` : ''}.

+
+
+ +
+
+ )} + +
+ +
+
+
+ +
+
+

Novo item 3D

+

Campos essenciais para começar produção e export.

+
+
+ +
+ + + +