Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/asset-studio',
|
||||||
|
icon: icon.mdiCubeOutline,
|
||||||
|
label: 'Asset Forge',
|
||||||
|
permissions: 'READ_ASSETS',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
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 { mdiArrowRight, mdiCubeOutline, mdiDatabaseSearchOutline, mdiFileUploadOutline, mdiShieldCheckOutline, mdiTimelineCheckOutline } from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import React, { ReactElement } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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 pipeline = ['Ideia', 'Blockout', 'Low Poly', 'Textura', 'Review', 'Export'];
|
||||||
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>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
return (
|
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>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className='min-h-screen bg-slate-950 text-white'>
|
||||||
<div
|
<header className='mx-auto flex max-w-7xl items-center justify-between px-6 py-6'>
|
||||||
className={`flex ${
|
<Link href='/' className='flex items-center gap-3 font-black tracking-tight'>
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<span className='rounded-2xl bg-cyan-300 p-2 text-slate-950'>
|
||||||
} min-h-screen w-full`}
|
<BaseIcon path={mdiCubeOutline} size={24} />
|
||||||
>
|
</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
Asset Forge 3D
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<nav className='flex items-center gap-3 text-sm font-bold'>
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<Link href='/login' className='rounded-full px-4 py-2 text-slate-200 transition hover:bg-white/10 hover:text-white'>
|
||||||
? videoBlock(illustrationVideo)
|
Login
|
||||||
: null}
|
</Link>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<Link href='/login' className='rounded-full bg-white px-4 py-2 text-slate-950 transition hover:bg-cyan-200'>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
Admin interface
|
||||||
<CardBoxComponentTitle title="Welcome to your Gerenciador de Assets 3D app!"/>
|
</Link>
|
||||||
|
</nav>
|
||||||
<div className="space-y-3">
|
</header>
|
||||||
<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
|
<section className='relative overflow-hidden'>
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<div className='rounded-[2rem] border border-white/10 bg-white/10 p-4 shadow-2xl shadow-black/30 backdrop-blur-xl'>
|
||||||
</CardBox>
|
<div className='rounded-[1.5rem] bg-slate-900 p-5'>
|
||||||
</div>
|
<div className='mb-5 flex items-center justify-between'>
|
||||||
</div>
|
<div>
|
||||||
</SectionFullScreen>
|
<p className='text-sm text-slate-400'>Asset card</p>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<h2 className='text-2xl font-black'>Sci‑Fi Supply Crate</h2>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
</div>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<span className='rounded-full bg-purple-400/20 px-3 py-1 text-xs font-bold text-purple-100'>Review</span>
|
||||||
Privacy Policy
|
</div>
|
||||||
</Link>
|
<div className='grid gap-3'>
|
||||||
</div>
|
{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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user