Assets 3D

This commit is contained in:
Flatlogic Bot 2026-07-28 20:21:53 +00:00
parent 5c9e04c5f4
commit abe7d8bbdb
8 changed files with 698 additions and 156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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'>SciFi 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>;
};

View File

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