Assets 3D
This commit is contained in:
parent
5c9e04c5f4
commit
abe7d8bbdb
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
481
frontend/src/pages/asset-studio/index.tsx
Normal file
481
frontend/src/pages/asset-studio/index.tsx
Normal file
@ -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<File | null>(null);
|
||||
const [recentAssets, setRecentAssets] = useState<AssetRecord[]>([]);
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetRecord | null>(null);
|
||||
const [createdSummary, setCreatedSummary] = useState<CreatedSummary | null>(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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 ? `<p>${form.brief}</p>` : 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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Asset Forge')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiCubeOutline} title='Asset Forge' main>
|
||||
<BaseButton href='/assets/assets-list' color='info' outline label='CRUD completo' />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<section className='mb-6 overflow-hidden rounded-3xl bg-slate-950 text-white shadow-2xl shadow-cyan-950/20'>
|
||||
<div className='grid gap-8 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.35),_transparent_32%),radial-gradient(circle_at_bottom_right,_rgba(168,85,247,0.35),_transparent_30%)] p-6 md:grid-cols-[1.25fr_0.75fr] md:p-8'>
|
||||
<div>
|
||||
<div className='mb-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-cyan-100'>
|
||||
MVP workflow para artistas 3D
|
||||
</div>
|
||||
<h2 className='max-w-3xl text-3xl font-black tracking-tight md:text-5xl'>
|
||||
Cadastre o item, versione o progresso e anexe o arquivo para o jogo.
|
||||
</h2>
|
||||
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-200 md:text-base'>
|
||||
Um fluxo curto para transformar ideias de props, armas, personagens ou cenários em assets rastreáveis com status, etapa e arquivo fonte/exportado.
|
||||
</p>
|
||||
<div className='mt-6 grid gap-3 text-sm sm:grid-cols-3'>
|
||||
{[
|
||||
['01', 'Brief do asset'],
|
||||
['02', 'Primeira versão'],
|
||||
['03', 'Arquivo FBX/GLB/PNG'],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className='rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur'>
|
||||
<span className='text-cyan-200'>{step}</span>
|
||||
<p className='mt-2 font-semibold'>{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur-md'>
|
||||
<p className='text-sm text-slate-200'>Prontidão do cadastro</p>
|
||||
<div className='mt-3 h-3 rounded-full bg-slate-800'>
|
||||
<div className='h-3 rounded-full bg-gradient-to-r from-cyan-300 to-fuchsia-400' style={{ width: `${completion}%` }} />
|
||||
</div>
|
||||
<p className='mt-3 text-4xl font-black'>{completion}%</p>
|
||||
<p className='mt-2 text-sm text-slate-300'>Preencha nome, briefing, versão, etapa, formato e arquivo para um pacote inicial completo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className='mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdSummary && (
|
||||
<div className='mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100'>
|
||||
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={28} />
|
||||
<div>
|
||||
<p className='font-bold'>Asset criado com sucesso: {createdSummary.asset.name}</p>
|
||||
<p className='text-sm opacity-80'>Versão {createdSummary.version?.version_name || 'v1'} registrada{createdSummary.assetFile ? ` com arquivo ${createdSummary.assetFile.format}` : ''}.</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton href={`/assets/assets-view/?id=${createdSummary.asset.id}`} color='success' label='Ver detalhe' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
||||
<CardBox className='border-0 bg-white/95 shadow-xl shadow-slate-200/70 dark:bg-dark-900 dark:shadow-black/20'>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='rounded-2xl bg-cyan-100 p-3 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-200'>
|
||||
<BaseIcon path={mdiLightbulbOnOutline} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-xl font-black'>Novo item 3D</h3>
|
||||
<p className='text-sm text-slate-500'>Campos essenciais para começar produção e export.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<label className='md:col-span-2'>
|
||||
<span className='mb-2 block text-sm font-bold'>Nome do asset *</span>
|
||||
<input name='name' value={form.name} onChange={updateField} className={inputClass} placeholder='Ex.: Cyberpunk Energy Crate' />
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Status</span>
|
||||
<select name='status' value={form.status} onChange={updateField} className={inputClass}>
|
||||
{statuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Prioridade</span>
|
||||
<select name='priority' value={form.priority} onChange={updateField} className={inputClass}>
|
||||
<option value='low'>Baixa</option>
|
||||
<option value='medium'>Média</option>
|
||||
<option value='high'>Alta</option>
|
||||
<option value='urgent'>Urgente</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className='md:col-span-2'>
|
||||
<span className='mb-2 block text-sm font-bold'>Brief para o artista</span>
|
||||
<textarea name='brief' value={form.brief} onChange={updateField} className={`${inputClass} min-h-[112px]`} placeholder='Silhueta, estilo visual, poly budget, tamanho, referências e uso no gameplay.' />
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Fonte / referência</span>
|
||||
<input name='source' value={form.source} onChange={updateField} className={inputClass} placeholder='Moodboard, ticket, concept art...' />
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Escala em metros</span>
|
||||
<input name='scale_meters' type='number' step='0.01' value={form.scale_meters} onChange={updateField} className={inputClass} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800/60'>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<BaseIcon path={mdiRocketLaunchOutline} size={24} />
|
||||
<h4 className='font-black'>Primeira versão</h4>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Nome da versão</span>
|
||||
<input name='version_name' value={form.version_name} onChange={updateField} className={inputClass} />
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Número</span>
|
||||
<input name='version_number' type='number' value={form.version_number} onChange={updateField} className={inputClass} />
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Etapa</span>
|
||||
<select name='stage' value={form.stage} onChange={updateField} className={inputClass}>
|
||||
{stages.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-3xl border border-dashed border-cyan-300 bg-cyan-50/60 p-4 dark:border-cyan-500/40 dark:bg-cyan-500/10'>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<BaseIcon path={mdiFileUploadOutline} size={24} />
|
||||
<div>
|
||||
<h4 className='font-black'>Arquivo inicial opcional</h4>
|
||||
<p className='text-xs text-slate-500 dark:text-slate-300'>Aceita FBX, GLB, GLTF, OBJ, BLEND, PNG, PSD, ZIP e formatos correlatos até 50 MB.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Tipo</span>
|
||||
<select name='file_kind' value={form.file_kind} onChange={updateField} className={inputClass}>
|
||||
<option value='model'>Modelo</option>
|
||||
<option value='texture'>Textura</option>
|
||||
<option value='source'>Fonte</option>
|
||||
<option value='preview'>Preview</option>
|
||||
<option value='other'>Outro</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Formato</span>
|
||||
<select name='format' value={form.format} onChange={updateField} className={inputClass}>
|
||||
{formats.map((item) => <option key={item} value={item}>{item.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className='mb-2 block text-sm font-bold'>Rótulo</span>
|
||||
<input name='label' value={form.label} onChange={updateField} className={inputClass} placeholder='LOD0, base color, source...' />
|
||||
</label>
|
||||
</div>
|
||||
<label className='mt-4 flex cursor-pointer flex-col items-center justify-center rounded-3xl border border-cyan-200 bg-white/80 px-4 py-8 text-center transition hover:border-cyan-400 hover:bg-white dark:border-cyan-500/30 dark:bg-dark-900/80'>
|
||||
<BaseIcon path={mdiFileUploadOutline} size={32} className='text-cyan-500' />
|
||||
<span className='mt-2 text-sm font-bold'>{file ? file.name : 'Clique para selecionar arquivo'}</span>
|
||||
<span className='mt-1 text-xs text-slate-500'>{file ? `${Math.round(file.size / 1024)} KB` : 'O upload acontece ao salvar o workflow.'}</span>
|
||||
<input type='file' className='hidden' onChange={(event) => setFile(event.target.files?.[0] || null)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<BaseButton type='submit' color='info' label={submitting ? 'Criando workflow...' : 'Criar asset + versão'} disabled={submitting} className='w-full sm:w-auto' />
|
||||
<BaseButton type='button' color='whiteDark' outline label='Limpar' onClick={() => { setForm(initialForm); setFile(null); setError(''); }} />
|
||||
</div>
|
||||
</form>
|
||||
</CardBox>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border-0 bg-slate-950 text-white shadow-xl shadow-slate-300/60 dark:shadow-black/20'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BaseIcon path={mdiFormatListBulletedSquare} size={24} />
|
||||
<h3 className='text-xl font-black'>Assets recentes</h3>
|
||||
</div>
|
||||
<Link href='/assets/assets-list' className='text-sm font-bold text-cyan-200 hover:text-white'>Todos</Link>
|
||||
</div>
|
||||
|
||||
{loadingList && <p className='rounded-2xl bg-white/10 p-4 text-sm text-slate-200'>Carregando assets...</p>}
|
||||
{!loadingList && !recentAssets.length && (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/10 p-5 text-sm text-slate-200'>
|
||||
Nenhum asset ainda. Crie o primeiro item 3D pelo formulário ao lado.
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-3'>
|
||||
{recentAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type='button'
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${selectedAsset?.id === asset.id ? 'border-cyan-300 bg-cyan-300/15' : 'border-white/10 bg-white/10 hover:border-white/30'}`}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-bold'>{asset.name || 'Asset sem nome'}</p>
|
||||
<p className='mt-1 line-clamp-1 text-xs text-slate-300'>{asset.source || asset.slug || 'Sem referência cadastrada'}</p>
|
||||
</div>
|
||||
<BaseIcon path={mdiChevronRight} size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border-0 bg-white shadow-xl shadow-slate-200/70 dark:bg-dark-900 dark:shadow-black/20'>
|
||||
<h3 className='mb-4 text-xl font-black'>Detalhe rápido</h3>
|
||||
{selectedAsset ? (
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-bold ${badgeStyles[selectedAsset.status || 'idea'] || badgeStyles.idea}`}>{selectedAsset.status || 'idea'}</span>
|
||||
<h4 className='mt-3 text-2xl font-black'>{selectedAsset.name || 'Asset sem nome'}</h4>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300' dangerouslySetInnerHTML={{ __html: selectedAsset.brief || 'Sem briefing cadastrado.' }} />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3 text-sm'>
|
||||
<div className='rounded-2xl bg-slate-100 p-4 dark:bg-dark-800'>
|
||||
<p className='text-slate-500'>Prioridade</p>
|
||||
<p className='font-bold'>{selectedAsset.priority || '—'}</p>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-slate-100 p-4 dark:bg-dark-800'>
|
||||
<p className='text-slate-500'>Escala</p>
|
||||
<p className='font-bold'>{selectedAsset.scale_meters || '—'} m</p>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton href={`/assets/assets-view/?id=${selectedAsset.id}`} color='info' label='Abrir ficha completa' className='w-full' />
|
||||
</div>
|
||||
) : (
|
||||
<p className='rounded-2xl bg-slate-100 p-4 text-sm text-slate-500 dark:bg-dark-800 dark:text-slate-300'>Selecione ou crie um asset para ver o detalhe.</p>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AssetStudioPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission='READ_ASSETS'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default AssetStudioPage;
|
||||
@ -1,166 +1,156 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { mdiArrowRight, mdiCubeOutline, mdiDatabaseSearchOutline, mdiFileUploadOutline, mdiShieldCheckOutline, mdiTimelineCheckOutline } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: mdiCubeOutline,
|
||||
title: 'Catálogo para itens 3D',
|
||||
text: 'Organize props, personagens, cenários, texturas e variações com status de produção.',
|
||||
},
|
||||
{
|
||||
icon: mdiTimelineCheckOutline,
|
||||
title: 'Status + versionamento',
|
||||
text: 'Acompanhe blockout, high poly, UV, textura, revisão e pacote pronto para exportar.',
|
||||
},
|
||||
{
|
||||
icon: mdiFileUploadOutline,
|
||||
title: 'Arquivos de produção',
|
||||
text: 'Anexe FBX, GLB, PNG, PSD, ZIP e referências ao histórico do asset.',
|
||||
},
|
||||
{
|
||||
icon: mdiShieldCheckOutline,
|
||||
title: 'Área segura para o time',
|
||||
text: 'Use login, permissões e interface administrativa para manter seu pipeline protegido.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Gerenciador de Assets 3D'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const pipeline = ['Ideia', 'Blockout', 'Low Poly', 'Textura', 'Review', 'Export'];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Asset Forge 3D')}</title>
|
||||
<meta
|
||||
name='description'
|
||||
content='Uma ferramenta web para criar, organizar e acompanhar assets 3D para jogos.'
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Gerenciador de Assets 3D app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className='min-h-screen bg-slate-950 text-white'>
|
||||
<header className='mx-auto flex max-w-7xl items-center justify-between px-6 py-6'>
|
||||
<Link href='/' className='flex items-center gap-3 font-black tracking-tight'>
|
||||
<span className='rounded-2xl bg-cyan-300 p-2 text-slate-950'>
|
||||
<BaseIcon path={mdiCubeOutline} size={24} />
|
||||
</span>
|
||||
Asset Forge 3D
|
||||
</Link>
|
||||
<nav className='flex items-center gap-3 text-sm font-bold'>
|
||||
<Link href='/login' className='rounded-full px-4 py-2 text-slate-200 transition hover:bg-white/10 hover:text-white'>
|
||||
Login
|
||||
</Link>
|
||||
<Link href='/login' className='rounded-full bg-white px-4 py-2 text-slate-950 transition hover:bg-cyan-200'>
|
||||
Admin interface
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section className='relative overflow-hidden'>
|
||||
<div className='absolute inset-0 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.28),transparent_28%),radial-gradient(circle_at_85%_25%,rgba(168,85,247,0.26),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0),#020617_82%)]' />
|
||||
<div className='relative mx-auto grid max-w-7xl gap-10 px-6 py-16 lg:grid-cols-[1.1fr_0.9fr] lg:py-24'>
|
||||
<div>
|
||||
<div className='mb-6 inline-flex rounded-full border border-cyan-300/30 bg-cyan-300/10 px-4 py-2 text-xs font-black uppercase tracking-[0.3em] text-cyan-100'>
|
||||
Web workflow para games
|
||||
</div>
|
||||
<h1 className='max-w-4xl text-5xl font-black leading-[0.95] tracking-tight md:text-7xl'>
|
||||
Crie e acompanhe assets 3D do briefing ao export.
|
||||
</h1>
|
||||
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-300'>
|
||||
Uma primeira versão prática para artistas 3D, indies e pequenos estúdios: cadastre itens, registre a primeira versão, anexe arquivos e acompanhe o progresso dentro da área autenticada.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<Link href='/login' className='inline-flex items-center justify-center rounded-full bg-cyan-300 px-6 py-3 font-black text-slate-950 shadow-xl shadow-cyan-950/30 transition hover:bg-cyan-200 focus:outline-none focus:ring-4 focus:ring-cyan-300/40'>
|
||||
Entrar e criar asset
|
||||
<BaseIcon path={mdiArrowRight} size={20} className='ml-2' />
|
||||
</Link>
|
||||
<Link href='/login' className='inline-flex items-center justify-center rounded-full border border-white/15 px-6 py-3 font-bold text-white transition hover:bg-white/10 focus:outline-none focus:ring-4 focus:ring-white/20'>
|
||||
Abrir admin interface
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<div className='rounded-[2rem] border border-white/10 bg-white/10 p-4 shadow-2xl shadow-black/30 backdrop-blur-xl'>
|
||||
<div className='rounded-[1.5rem] bg-slate-900 p-5'>
|
||||
<div className='mb-5 flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm text-slate-400'>Asset card</p>
|
||||
<h2 className='text-2xl font-black'>Sci‑Fi Supply Crate</h2>
|
||||
</div>
|
||||
<span className='rounded-full bg-purple-400/20 px-3 py-1 text-xs font-bold text-purple-100'>Review</span>
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{pipeline.map((item, index) => (
|
||||
<div key={item} className='flex items-center gap-3 rounded-2xl bg-white/5 p-3'>
|
||||
<div className={`h-3 w-3 rounded-full ${index < 4 ? 'bg-cyan-300' : 'bg-slate-600'}`} />
|
||||
<span className={index < 4 ? 'text-white' : 'text-slate-500'}>{item}</span>
|
||||
{index === 3 && <span className='ml-auto rounded-full bg-cyan-300/10 px-2 py-1 text-xs text-cyan-100'>agora</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-5 rounded-2xl border border-dashed border-cyan-300/30 bg-cyan-300/10 p-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<BaseIcon path={mdiFileUploadOutline} size={24} className='text-cyan-200' />
|
||||
<div>
|
||||
<p className='font-bold'>crate_lod0.glb</p>
|
||||
<p className='text-xs text-slate-400'>Versão v1 registrada para gameplay test</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<section className='mx-auto max-w-7xl px-6 pb-16'>
|
||||
<div className='grid gap-4 md:grid-cols-4'>
|
||||
{features.map((feature) => (
|
||||
<article key={feature.title} className='rounded-3xl border border-white/10 bg-white/[0.06] p-5 backdrop-blur'>
|
||||
<BaseIcon path={feature.icon} size={28} className='mb-4 text-cyan-200' />
|
||||
<h3 className='text-lg font-black'>{feature.title}</h3>
|
||||
<p className='mt-3 text-sm leading-6 text-slate-400'>{feature.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='border-t border-white/10 bg-white/[0.03]'>
|
||||
<div className='mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10 md:flex-row md:items-center md:justify-between'>
|
||||
<div>
|
||||
<p className='text-sm uppercase tracking-[0.3em] text-cyan-200'>Primeiro MVP entregue</p>
|
||||
<h2 className='mt-2 text-3xl font-black'>Entre no app e use o Asset Forge.</h2>
|
||||
</div>
|
||||
<Link href='/login' className='inline-flex items-center justify-center rounded-full bg-white px-6 py-3 font-black text-slate-950 transition hover:bg-cyan-200'>
|
||||
Login / Admin
|
||||
<BaseIcon path={mdiDatabaseSearchOutline} size={20} className='ml-2' />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className='mx-auto flex max-w-7xl flex-col gap-3 px-6 py-8 text-sm text-slate-500 md:flex-row md:items-center md:justify-between'>
|
||||
<p>© 2026 Asset Forge 3D. All rights reserved.</p>
|
||||
<Link href='/privacy-policy/' className='hover:text-cyan-200'>Privacy Policy</Link>
|
||||
</footer>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user