remove node_modules
@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Stage 1: build (Node) ───────────────────────────────────────────────────
|
||||
# Compila o site Vite. Corre `tsc -b` (gate de TypeScript) + `vite build`.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Instala dependências primeiro (camada cacheável).
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Copia o resto do código e gera o build estático.
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: runtime (nginx) ────────────────────────────────────────────────
|
||||
# Imagem final isolada: serve `dist/` estático. Sem Node nem código-fonte.
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# Config nginx (SPA fallback + gzip + cache de assets).
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Conteúdo estático compilado.
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Healthcheck simples (página inicial responde 200).
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@ -0,0 +1,56 @@
|
||||
# RESILIENCE — Microsite
|
||||
|
||||
Single-page institutional microsite for the **RESILIENCE** research project
|
||||
(*Residential Energy-Efficiency Strategies Incorporating Lifecycle Sustainability
|
||||
and Circular Economy*), INESC Coimbra.
|
||||
|
||||
- **Project code (Streamline):** SLC20260008
|
||||
- **Client:** INESC Coimbra — Álvaro Gomes
|
||||
- **Methodology:** Spec-Driven Development — see [`specs/`](./specs)
|
||||
|
||||
## Stack
|
||||
Vite · React · TypeScript · Tailwind CSS v4 · shadcn/ui · @tanstack/react-router
|
||||
· framer-motion · lucide-react.
|
||||
|
||||
## Getting started
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # dev server (http://localhost:5173)
|
||||
npm run build # type-check (tsc -b) + production build
|
||||
npm run preview # preview the production build
|
||||
```
|
||||
|
||||
> **Requisito:** Node.js ≥ 18 e npm instalados e disponíveis no PATH.
|
||||
|
||||
## Docker (funcionamento isolado — recomendado)
|
||||
Não requer Node no host: o build corre dentro da imagem (multi-stage node→nginx).
|
||||
```bash
|
||||
docker build -t resilience-microsite .
|
||||
docker run --rm -p 8080:80 resilience-microsite # http://localhost:8080
|
||||
# ou:
|
||||
docker compose up --build
|
||||
```
|
||||
Ver [`specs/06-deployment.md`](./specs/06-deployment.md) para detalhes.
|
||||
|
||||
## Structure
|
||||
```
|
||||
src/
|
||||
data/project.ts # conteúdo canónico (fonte: input_files/)
|
||||
lib/ # utils + scroll helper
|
||||
components/
|
||||
ui/ # primitivos shadcn (button, card, badge, dialog, sheet)
|
||||
layout/ # Header, Footer, Section
|
||||
sections/ # Hero, Goal, WorkPlan, Gantt, Indicators
|
||||
App.tsx # composição da single page
|
||||
router.tsx # @tanstack/react-router (rota /)
|
||||
```
|
||||
|
||||
## Sections
|
||||
Header (anchors: Home · Work plan · Indicators) → Hero → Goal & contribution →
|
||||
Work plan (cards + interactive Gantt) → Indicators (clickable cards) → Footer
|
||||
(partners + funding).
|
||||
|
||||
## Notes
|
||||
- Conteúdo em inglês; fonte de verdade em `input_files/` (não inventar conteúdo).
|
||||
- Logos dos parceiros são placeholders até serem fornecidos (ver `specs/CHANGELOG.md`).
|
||||
- Paleta navy institucional definida em `src/index.css` (afinável).
|
||||
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
services:
|
||||
microsite:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: resilience-microsite:latest
|
||||
container_name: resilience-microsite
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RESILIENCE — Residential Energy-Efficiency Strategies</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="RESILIENCE — Residential Energy-Efficiency Strategies Incorporating Lifecycle Sustainability and Circular Economy. INESC Coimbra research project."
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 28 KiB |
@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Compressão para texto/JS/CSS.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/xml application/xml+rss;
|
||||
|
||||
# Assets com hash (Vite -> /assets/*.[hash].*): cache longo e imutável.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback: qualquer rota/refresh devolve index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# index.html nunca em cache (garante deploy fresco).
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Páginas de erro também caem no SPA.
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "resilience-microsite",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Microsite RESILIENCE — INESC Coimbra (projeto SLC20260008, Streamline)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tanstack/react-router-devtools": "^1.95.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
**Prompt de Arranque — Sessão do Agente**
|
||||
|
||||
- **Contexto:** Projeto `SLC20260008` — Microsite "Resilience" (INESCC / Streamline).
|
||||
- **Ficheiros relevantes:** `boot.md`, pasta `specs/`, cronograma.png e Notas Site.docx na pasta `input_files/` (usar como fonte de verdade).
|
||||
|
||||
Antes de responder
|
||||
1. Lê e entende completamente `boot.md`.
|
||||
2. Não escrever código nem alterar ficheiros de código até a especificação SDD estar confirmada ou atualizada.
|
||||
|
||||
Objetivo da sessão
|
||||
- Implementar o código fonte relacionado com `boot.md` seguindo a metodologia Spec-Driven Development (SDD).
|
||||
|
||||
Entregáveis esperados
|
||||
- Versão do SDD atualizada (se necessário) antes de qualquer alteração de código.
|
||||
- Código scaffold e/ou componentes implementados apenas após confirmação da SDD.
|
||||
|
||||
Regras e restrições
|
||||
- Responder em português.
|
||||
- Ser conciso e direto; priorizar tarefas conforme o SDD.
|
||||
- Utilizar Tailwind + shadcn + @tanstack/react-router + framer-motion + lucide conforme `boot.md`.
|
||||
- Manter rastreabilidade: registar alterações em `specs/` e referenciar ficheiros modificados.
|
||||
|
||||
Critérios de aceitação (por entrega)
|
||||
- Para SDD: ficheiro em `specs/` atualizado e aceite.
|
||||
- Para código: aplicação que arranca com `npm install` e `npm run dev` sem erros TypeScript; rotas principais funcionais.
|
||||
|
||||
Fluxo recomendado
|
||||
1. Validar SDD (ler `boot.md` e `specs/`).
|
||||
2. Propor alterações na SDD (se necessário) e aguardar aprovação.
|
||||
3. Após aprovação, implementar em small increments (Header → Hero → Secções → Testes).
|
||||
|
||||
Caso de dúvida
|
||||
- Pergunte antes de codificar.
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0b2a4a"/>
|
||||
<rect x="20" y="14" width="7" height="36" rx="3.5" fill="#1e6fd9"/>
|
||||
<path d="M34 14h10a11 11 0 0 1 0 22h-3l9 14h-9l-8-13v13h-7V14h8z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 712 KiB |
@ -0,0 +1,35 @@
|
||||
# 00 — Visão geral
|
||||
|
||||
## Objetivo
|
||||
Microsite **single-page** institucional para apresentação do projeto científico
|
||||
**RESILIENCE** (*Residential Energy-Efficiency Strategies Incorporating Lifecycle
|
||||
Sustainability and Circular Economy*).
|
||||
|
||||
## Âmbito
|
||||
- Uma só página com navegação por âncoras e scroll suave.
|
||||
- Idioma **obrigatoriamente inglês** (conteúdo). UI e copy em inglês.
|
||||
- Visual institucional + moderno, alinhado com o design system do INESCC
|
||||
(https://www.inescc.pt/) — paleta navy/azul, alto contraste, cantos suaves.
|
||||
- Responsivo (mobile-first).
|
||||
- **Empacotado em Docker** para funcionamento independente e isolado (sem
|
||||
dependências no host). Ver [06-deployment.md](./06-deployment.md).
|
||||
|
||||
## Fontes de verdade (`input_files/`)
|
||||
- `Notas Site.docx` — conteúdo textual canónico (título, equipa, datas, custos,
|
||||
código, beneficiários, sinopse, work plan, indicadores). Ver [03-content.md](./03-content.md).
|
||||
- `cronograma.png` — base visual do componente de cronograma (Gantt) do Work Plan.
|
||||
|
||||
## Secções obrigatórias (hierarquia)
|
||||
1. **Header** — nome do projeto + navegação (Home, Work plan, Indicadores).
|
||||
2. **Hero** — nome, descrição, equipa, datas, project code, suporte financeiro.
|
||||
3. **Objetivos e contributo** — Goal + pontos-chave de contributo (cartões/ícones).
|
||||
4. **Work plan** — cartões por tarefa; hover (desktop) / clique (mobile) revela cronograma.
|
||||
5. **Indicadores** — journals, conferências, dissertações; cartões clicáveis com detalhe.
|
||||
6. **Footer** — logos/cartões dos parceiros (beneficiários) + suporte financeiro.
|
||||
|
||||
## Critérios de aceitação (resumo)
|
||||
- `npm install` + `npm run dev` arrancam sem erros.
|
||||
- Sem erros de TypeScript (`tsc --noEmit` / `npm run build` limpo).
|
||||
- Âncoras principais (#home, #work-plan, #indicators) acessíveis e funcionais.
|
||||
|
||||
Detalhe em [05-acceptance.md](./05-acceptance.md).
|
||||
@ -0,0 +1,63 @@
|
||||
# 02 — Arquitetura
|
||||
|
||||
## Stack
|
||||
- **Build:** Vite + React + TypeScript.
|
||||
- **Estilos:** Tailwind CSS (`@tailwindcss/vite`).
|
||||
- **Componentes:** shadcn/ui (Radix + Tailwind).
|
||||
- **Routing/âncoras:** `@tanstack/react-router` (scroll suave entre secções).
|
||||
- **Animações:** `framer-motion` (motion).
|
||||
- **Ícones:** `lucide-react`.
|
||||
- **Alias:** `@/*` → `./src/*` (tsconfig + vite resolve).
|
||||
- **Empacotamento/runtime:** Docker (multi-stage node→nginx) para execução
|
||||
isolada e independente do host. Ver [06-deployment.md](./06-deployment.md).
|
||||
|
||||
## Estrutura de pastas (proposta)
|
||||
```
|
||||
src/
|
||||
main.tsx # bootstrap + RouterProvider
|
||||
router.tsx # rootRoute + indexRoute (single page)
|
||||
index.css # Tailwind + tokens (CSS vars do design system)
|
||||
lib/
|
||||
utils.ts # cn() (shadcn)
|
||||
scroll.ts # helper de scroll suave para âncoras
|
||||
data/
|
||||
project.ts # conteúdo canónico (de 03-content.md), tipado
|
||||
components/
|
||||
ui/ # primitivos shadcn (button, card, sheet, badge, dialog...)
|
||||
layout/
|
||||
Header.tsx
|
||||
Footer.tsx
|
||||
Section.tsx # wrapper de secção (id âncora + animação in-view)
|
||||
sections/
|
||||
Hero.tsx
|
||||
Goal.tsx # Objetivos e contributo
|
||||
WorkPlan.tsx
|
||||
Gantt.tsx # cronograma (inspirado em cronograma.png)
|
||||
WorkPlanCard.tsx
|
||||
Indicators.tsx
|
||||
IndicatorCard.tsx
|
||||
App.tsx # composição das secções na ordem da spec
|
||||
|
||||
# raiz (deployment — ver 06-deployment.md)
|
||||
Dockerfile # multi-stage: node (build) → nginx (runtime)
|
||||
.dockerignore # exclui node_modules/dist/.git do contexto
|
||||
nginx.conf # SPA fallback + gzip + cache de assets
|
||||
docker-compose.yml # build + port mapping (8080:80) + healthcheck
|
||||
```
|
||||
|
||||
## Routing / navegação
|
||||
- Single-page: uma rota `/` que renderiza todas as secções em sequência.
|
||||
- Navegação por **âncoras**: `#home`, `#goal`, `#work-plan`, `#indicators`.
|
||||
- Scroll suave via helper (`element.scrollIntoView({ behavior: 'smooth' })`)
|
||||
acionado pelos itens de menu; TanStack Router para estrutura e (opcional) hash.
|
||||
- IDs estáveis em cada `<Section>` para deep-linking.
|
||||
|
||||
## Dados
|
||||
- Sem backend. Conteúdo estático tipado em `src/data/project.ts`, derivado
|
||||
exclusivamente de [03-content.md](./03-content.md).
|
||||
- Logos dos parceiros como assets em `src/assets/partners/` (placeholder se em falta).
|
||||
|
||||
## Decisões abertas (precisam validação)
|
||||
1. Logos oficiais dos parceiros — não fornecidos em `input_files/`; usar
|
||||
placeholder textual/cartão até receber ficheiros? **(assumir placeholder)**
|
||||
2. Hex exatos da marca INESCC — usar paleta proposta em 01 e afinar depois.
|
||||
@ -0,0 +1,60 @@
|
||||
# 02 — Arquitetura
|
||||
|
||||
## Stack
|
||||
- **Build:** Vite + React + TypeScript.
|
||||
- **Estilos:** Tailwind CSS (`@tailwindcss/vite`).
|
||||
- **Componentes:** shadcn/ui (Radix + Tailwind).
|
||||
- **Routing/âncoras:** `@tanstack/react-router` (scroll suave entre secções).
|
||||
- **Animações:** `framer-motion` (motion).
|
||||
- **Ícones:** `lucide-react`.
|
||||
- **Alias:** `@/*` → `./src/*` (tsconfig + vite resolve).
|
||||
- **Solução baseada em docker**.
|
||||
|
||||
## Tipo de sistema
|
||||
- Docker container para execução independente
|
||||
- Utilizar nginx.conf para servir os ficheiros estáticos gerados pelo build do Vite.
|
||||
|
||||
## Estrutura de pastas (proposta)
|
||||
```
|
||||
src/
|
||||
main.tsx # bootstrap + RouterProvider
|
||||
router.tsx # rootRoute + indexRoute (single page)
|
||||
index.css # Tailwind + tokens (CSS vars do design system)
|
||||
lib/
|
||||
utils.ts # cn() (shadcn)
|
||||
scroll.ts # helper de scroll suave para âncoras
|
||||
data/
|
||||
project.ts # conteúdo canónico (de 03-content.md), tipado
|
||||
components/
|
||||
ui/ # primitivos shadcn (button, card, sheet, badge, dialog...)
|
||||
layout/
|
||||
Header.tsx
|
||||
Footer.tsx
|
||||
Section.tsx # wrapper de secção (id âncora + animação in-view)
|
||||
sections/
|
||||
Hero.tsx
|
||||
Goal.tsx # Objetivos e contributo
|
||||
WorkPlan.tsx
|
||||
Gantt.tsx # cronograma (inspirado em cronograma.png)
|
||||
WorkPlanCard.tsx
|
||||
Indicators.tsx
|
||||
IndicatorCard.tsx
|
||||
App.tsx # composição das secções na ordem da spec
|
||||
```
|
||||
|
||||
## Routing / navegação
|
||||
- Single-page: uma rota `/` que renderiza todas as secções em sequência.
|
||||
- Navegação por **âncoras**: `#home`, `#goal`, `#work-plan`, `#indicators`.
|
||||
- Scroll suave via helper (`element.scrollIntoView({ behavior: 'smooth' })`)
|
||||
acionado pelos itens de menu; TanStack Router para estrutura e (opcional) hash.
|
||||
- IDs estáveis em cada `<Section>` para deep-linking.
|
||||
|
||||
## Dados
|
||||
- Sem backend. Conteúdo estático tipado em `src/data/project.ts`, derivado
|
||||
exclusivamente de [03-content.md](./03-content.md).
|
||||
- Logos dos parceiros como assets em `src/assets/partners/` (placeholder se em falta).
|
||||
|
||||
## Decisões abertas (precisam validação)
|
||||
1. Logos oficiais dos parceiros — não fornecidos em `input_files/`; usar
|
||||
placeholder textual/cartão até receber ficheiros? **(assumir placeholder)**
|
||||
2. Hex exatos da marca INESCC — usar paleta proposta em 01 e afinar depois.
|
||||
@ -0,0 +1,43 @@
|
||||
# 05 — Critérios de aceitação e verificação
|
||||
|
||||
## Funcionais
|
||||
- [ ] `npm install` conclui sem erros.
|
||||
- [ ] `npm run dev` arranca e serve a aplicação sem erros de runtime.
|
||||
- [ ] `npm run build` (ou `tsc --noEmit`) sem **erros de TypeScript**.
|
||||
- [ ] Âncoras `#home`, `#work-plan`, `#indicators` (+`#goal`) navegáveis com scroll suave.
|
||||
- [ ] Header colapsa em menu < md; links funcionam em mobile e desktop.
|
||||
- [ ] Work plan: hover (desktop) e clique (mobile) revelam o cronograma.
|
||||
- [ ] Indicators: cartões clicáveis abrem detalhe.
|
||||
- [ ] Footer lista os 3 beneficiários e o suporte financeiro.
|
||||
|
||||
## Containerização (Docker) — ver [06-deployment.md](./06-deployment.md)
|
||||
- [ ] **RF-D1** `docker build -t resilience-microsite .` conclui sem erros (o build
|
||||
corre `npm run build` → `tsc -b`; erros de TS fazem o build falhar).
|
||||
- [ ] **RF-D2** `docker run -p 8080:80 resilience-microsite` serve o site em
|
||||
http://localhost:8080 sem depender de Node/npm no host.
|
||||
- [ ] **RF-D3** `docker compose up --build` arranca o serviço com a porta mapeada.
|
||||
- [ ] **RF-D4** Refresh/deep-link de uma âncora (ex.: `/#work-plan`) devolve 200
|
||||
(SPA fallback do nginx para `index.html`).
|
||||
- [ ] **RF-D5** Imagem final isolada e sem estado: não inclui código-fonte nem
|
||||
Node; não requer volumes nem serviços externos.
|
||||
- [ ] **RF-D6** Versões base pinadas e `.dockerignore` exclui `node_modules`/`dist`.
|
||||
|
||||
## Conteúdo
|
||||
- [ ] Todo o texto em inglês e fiel a [03-content.md](./03-content.md) (sem invenção).
|
||||
- [ ] Datas, código de projeto, montantes e DOI corretos.
|
||||
|
||||
## Visual / UX
|
||||
- [ ] Paleta navy institucional INESCC + tipografia Outfit aplicadas.
|
||||
- [ ] Responsivo (mobile → desktop) sem overflow/quebras.
|
||||
- [ ] Animações discretas; respeitam `prefers-reduced-motion`.
|
||||
|
||||
## Rastreabilidade
|
||||
- [ ] Cada incremento de código referenciado em [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
## Plano de verificação
|
||||
1. **Com Node local:** `npm install` → `npm run build` (gate TypeScript) →
|
||||
`npm run dev` → inspeção manual das 6 secções + navegação.
|
||||
2. **Sem Node local (recomendado nesta máquina):** `docker build` (gate TS dentro
|
||||
da imagem) → `docker run -p 8080:80` → inspeção manual em http://localhost:8080.
|
||||
3. Teste responsivo (largura mobile/desktop) e teclado/scroll.
|
||||
4. Teste de SPA fallback: refrescar com hash `/#indicators` devolve a página.
|
||||
@ -0,0 +1,49 @@
|
||||
# 06 — Deployment & containerização (Docker)
|
||||
|
||||
## Objetivo
|
||||
Encapsular o microsite num **contentor Docker** para funcionamento **independente
|
||||
e isolado**: a aplicação corre da mesma forma em qualquer host com Docker, sem
|
||||
depender de um Node/npm instalado na máquina nem de configuração externa.
|
||||
|
||||
> Motivação adicional: nesta máquina não existe Node/npm instalados — o Docker
|
||||
> remove essa dependência do host (o build corre dentro da imagem). Ver
|
||||
> [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
## Estratégia
|
||||
**Multi-stage build:**
|
||||
1. **Stage `build`** (`node:22-alpine`): `npm ci` (ou `npm install`) + `npm run
|
||||
build` → gera `dist/` (estático Vite).
|
||||
2. **Stage `runtime`** (`nginx:1.27-alpine`): serve o `dist/` como conteúdo
|
||||
estático. Imagem final pequena, sem Node nem código-fonte.
|
||||
|
||||
Como é uma SPA single-page, o runtime é um servidor estático; o nginx trata de
|
||||
fallback de rotas para `index.html` e do cache de assets.
|
||||
|
||||
## Artefactos a criar (após aprovação)
|
||||
| Ficheiro | Função |
|
||||
|----------|--------|
|
||||
| `Dockerfile` | Build multi-stage (node → nginx) |
|
||||
| `.dockerignore` | Excluir `node_modules`, `dist`, `.git`, etc. do contexto |
|
||||
| `nginx.conf` | Config nginx: porta 80, SPA fallback, gzip, cache de assets |
|
||||
| `docker-compose.yml` | Orquestração local (build + port mapping + healthcheck) |
|
||||
|
||||
## Contrato de operação
|
||||
- **Build:** `docker build -t resilience-microsite .`
|
||||
- **Run:** `docker run --rm -p 8080:80 resilience-microsite` → http://localhost:8080
|
||||
- **Compose:** `docker compose up --build` (porta exposta `8080:80` por defeito).
|
||||
- **Porta interna:** 80 (nginx). **Porta externa:** configurável (default 8080).
|
||||
- **Sem estado:** não há volumes nem dependências externas (site 100% estático).
|
||||
- **Healthcheck:** GET `/` devolve 200.
|
||||
|
||||
## nginx — requisitos
|
||||
- `try_files $uri $uri/ /index.html;` (suporte a deep-link de âncoras/refresh).
|
||||
- `gzip on` para HTML/CSS/JS.
|
||||
- Cache longo para assets com hash (`/assets/*`), `no-cache` para `index.html`.
|
||||
- Servir em IPv4/IPv6, porta 80.
|
||||
|
||||
## Notas
|
||||
- O `Dockerfile` torna a verificação do gate de TypeScript reprodutível mesmo sem
|
||||
Node no host: `npm run build` (que corre `tsc -b`) executa no stage de build; se
|
||||
houver erros de TS, o `docker build` falha — passando a ser o método de
|
||||
verificação de aceitação enquanto não houver Node instalado localmente.
|
||||
- Versões base pinadas (`node:22-alpine`, `nginx:1.27-alpine`) para reprodutibilidade.
|
||||
@ -0,0 +1,31 @@
|
||||
# SDD — Microsite "RESILIENCE"
|
||||
|
||||
**Código de projeto:** SLC20260008
|
||||
**Cliente:** INESC Coimbra — Álvaro Gomes
|
||||
**Empresa:** Streamline (web-design / web-development)
|
||||
**Metodologia:** Spec-Driven Development (SDD)
|
||||
|
||||
Especificação que governa a implementação do microsite. Nenhum código deve ser
|
||||
escrito ou alterado sem que a SDD correspondente esteja aceite. Toda a alteração
|
||||
de código deve referenciar a secção da spec que a justifica.
|
||||
|
||||
## Estrutura
|
||||
|
||||
| Ficheiro | Conteúdo |
|
||||
|----------|----------|
|
||||
| [00-overview.md](./00-overview.md) | Objetivo, âmbito, fontes de verdade, critérios de aceitação |
|
||||
| [01-design-system.md](./01-design-system.md) | Cores, tipografia, espaçamento, tokens shadcn/Tailwind |
|
||||
| [02-architecture.md](./02-architecture.md) | Stack, estrutura de pastas, routing, navegação, animações |
|
||||
| [03-content.md](./03-content.md) | Conteúdo canónico extraído de `input_files/` (fonte de verdade) |
|
||||
| [04-sections.md](./04-sections.md) | Especificação por secção/componente |
|
||||
| [05-acceptance.md](./05-acceptance.md) | Critérios de aceitação e plano de verificação |
|
||||
| [06-deployment.md](./06-deployment.md) | Containerização Docker (funcionamento isolado) |
|
||||
| [CHANGELOG.md](./CHANGELOG.md) | Rastreabilidade: decisões e ficheiros alterados |
|
||||
|
||||
## Estado
|
||||
|
||||
- [x] SDD redigida (v0.1) e aprovada
|
||||
- [x] Scaffold do projeto (Vite + TS + Tailwind + shadcn + router)
|
||||
- [x] Header → Hero → Secções → Footer
|
||||
- [x] Containerização Docker (v0.3 — [06-deployment.md](./06-deployment.md)) — ficheiros criados
|
||||
- [ ] Verificação dos critérios de aceitação (via `docker build`) — **pendente: Docker não instalado**
|
||||
@ -0,0 +1,33 @@
|
||||
**Prompt de Arranque — Sessão do Agente**
|
||||
|
||||
- **Contexto:** Projeto `SLC20260008` — Microsite "Resilience" (INESCC / Streamline).
|
||||
- **Ficheiros relevantes:** `boot.md`, pasta `specs/`, cronograma.png e Notas Site.docx na pasta `input_files/` (usar como fonte de verdade).
|
||||
|
||||
Antes de responder
|
||||
1. Lê e entende completamente `boot.md`.
|
||||
2. Não escrever código nem alterar ficheiros de código até a especificação SDD estar confirmada ou atualizada.
|
||||
|
||||
Objetivo da sessão
|
||||
- Implementar o código fonte relacionado com `boot.md` seguindo a metodologia Spec-Driven Development (SDD).
|
||||
|
||||
Entregáveis esperados
|
||||
- Versão do SDD atualizada (se necessário) antes de qualquer alteração de código.
|
||||
- Código scaffold e/ou componentes implementados apenas após confirmação da SDD.
|
||||
|
||||
Regras e restrições
|
||||
- Responder em português.
|
||||
- Ser conciso e direto; priorizar tarefas conforme o SDD.
|
||||
- Utilizar Tailwind + shadcn + @tanstack/react-router + framer-motion + lucide conforme `boot.md`.
|
||||
- Manter rastreabilidade: registar alterações em `specs/` e referenciar ficheiros modificados.
|
||||
|
||||
Critérios de aceitação (por entrega)
|
||||
- Para SDD: ficheiro em `specs/` atualizado e aceite.
|
||||
- Para código: aplicação que arranca com `npm install` e `npm run dev` sem erros TypeScript; rotas principais funcionais.
|
||||
|
||||
Fluxo recomendado
|
||||
1. Validar SDD (ler `boot.md` e `specs/`).
|
||||
2. Propor alterações na SDD (se necessário) e aguardar aprovação.
|
||||
3. Após aprovação, implementar em small increments (Header → Hero → Secções → Testes).
|
||||
|
||||
Caso de dúvida
|
||||
- Pergunte antes de codificar.
|
||||
@ -0,0 +1,21 @@
|
||||
import { Header } from "@/components/layout/Header"
|
||||
import { Footer } from "@/components/layout/Footer"
|
||||
import { Hero } from "@/components/sections/Hero"
|
||||
import { Goal } from "@/components/sections/Goal"
|
||||
import { WorkPlan } from "@/components/sections/WorkPlan"
|
||||
import { Indicators } from "@/components/sections/Indicators"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<Goal />
|
||||
<WorkPlan />
|
||||
<Indicators />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { project, partners, funders } from "@/data/project";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-primary text-primary-foreground">
|
||||
<div className="mx-auto w-full max-w-6xl px-5 py-16 md:px-8">
|
||||
<div className="mb-4 text-sm font-semibold uppercase tracking-widest text-brand-foreground/70">
|
||||
Beneficiaries
|
||||
</div>
|
||||
|
||||
{/* Partner cards (placeholders — logos a fornecer, ver specs/CHANGELOG.md) */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{partners.map((p) => (
|
||||
<Card
|
||||
key={p.short}
|
||||
className="group border-white/15 bg-transparent text-primary-foreground transition-colors hover:bg-white/10"
|
||||
>
|
||||
<div className="flex h-32 items-center justify-center p-6">
|
||||
<img
|
||||
src={p.image}
|
||||
alt={p.name}
|
||||
className="max-h-full w-auto object-contain opacity-80 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<p className="text-sm font-medium">{p.name}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Funding */}
|
||||
<div className="mt-12 grid gap-8 border-t border-white/15 pt-10 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-widest text-brand-foreground/70">
|
||||
Financial support
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{funders.map((f) => (
|
||||
<span
|
||||
key={f.name}
|
||||
className="rounded-md border border-white/20 bg-white/80 px-3 py-1.5 text-sm text-primary-foreground/90"
|
||||
>
|
||||
<img src={f.image} alt={f.name} className="inline h-10" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<dl className="mt-5 space-y-1 text-sm text-primary-foreground/75">
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-primary-foreground/55">EU support:</dt>
|
||||
<dd>{project.funding.euSupport}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-primary-foreground/55">
|
||||
National support:
|
||||
</dt>
|
||||
<dd>{project.funding.nationalSupport}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-primary-foreground/55">
|
||||
Total eligible cost:
|
||||
</dt>
|
||||
<dd>{project.funding.totalEligibleCost}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="md:text-right">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-widest text-brand-foreground/70">
|
||||
Project reference
|
||||
</div>
|
||||
<p className="text-sm text-primary-foreground/80">
|
||||
{project.projectCode}
|
||||
</p>
|
||||
<a
|
||||
href={project.doi}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-primary-foreground/90 underline-offset-4 hover:underline md:justify-end"
|
||||
>
|
||||
{project.doi.replace("https://", "")}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 border-t border-white/15 pt-6 text-xs text-primary-foreground/55">
|
||||
© {project.dates.end.slice(-4)} {project.acronym} — INESC Coimbra.
|
||||
Microsite developed by Streamline.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Menu } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { navItems, scrollToSection } from "@/lib/scroll"
|
||||
import { project } from "@/data/project"
|
||||
|
||||
export function Header() {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8)
|
||||
onScroll()
|
||||
window.addEventListener("scroll", onScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-40 transition-all duration-300",
|
||||
scrolled
|
||||
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-5 md:px-8">
|
||||
<button
|
||||
onClick={() => scrollToSection("home")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-lg font-bold tracking-tight focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors duration-300",
|
||||
scrolled ? "text-primary" : "text-white"
|
||||
)}
|
||||
>
|
||||
<span className="inline-block h-5 w-1.5 rounded-full bg-brand" />
|
||||
{project.acronym}
|
||||
</button>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-sm font-medium transition-colors duration-300",
|
||||
scrolled
|
||||
? "text-muted-foreground hover:bg-secondary hover:text-primary"
|
||||
: "text-white/80 hover:bg-white/10 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className="md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Open menu"
|
||||
className={cn(
|
||||
"transition-colors duration-300",
|
||||
!scrolled && "border-white/30 bg-transparent text-white hover:bg-white/10 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{project.acronym}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<SheetClose asChild key={item.id}>
|
||||
<button
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
className="rounded-md px-3 py-3 text-left text-base font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</SheetClose>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SectionProps {
|
||||
id: string
|
||||
className?: string
|
||||
containerClassName?: string
|
||||
eyebrow?: string
|
||||
title?: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Section({
|
||||
id,
|
||||
className,
|
||||
containerClassName,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<section id={id} className={cn("scroll-mt-20 py-20 md:py-28", className)}>
|
||||
<div className={cn("mx-auto w-full max-w-6xl px-5 md:px-8", containerClassName)}>
|
||||
{(eyebrow || title || description) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="mb-12 max-w-2xl"
|
||||
>
|
||||
{eyebrow && (
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-brand">
|
||||
{eyebrow}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-foreground md:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
import { Flag, FileText } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { tasks, milestones, reports, timeline } from "@/data/project"
|
||||
|
||||
const MONTH_ABBR = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
]
|
||||
|
||||
// Constrói segmentos de ano a partir do mês inicial (ago/2025) e total de meses.
|
||||
function buildYearSegments() {
|
||||
const segments: { year: number; span: number; startMonthIndex: number }[] = []
|
||||
let monthOfYear = timeline.startMonthOfYear // 8 = agosto
|
||||
let year = timeline.startYear
|
||||
let current = { year, span: 0, startMonthIndex: 1 }
|
||||
for (let m = 1; m <= timeline.totalMonths; m++) {
|
||||
if (current.span === 0) current.startMonthIndex = m
|
||||
current.span++
|
||||
if (monthOfYear === 12) {
|
||||
segments.push(current)
|
||||
year++
|
||||
monthOfYear = 1
|
||||
current = { year, span: 0, startMonthIndex: m + 1 }
|
||||
} else {
|
||||
monthOfYear++
|
||||
}
|
||||
}
|
||||
if (current.span > 0) segments.push(current)
|
||||
return segments
|
||||
}
|
||||
|
||||
const pct = (n: number) => (n / timeline.totalMonths) * 100
|
||||
|
||||
interface GanttProps {
|
||||
activeTaskId?: string | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function Gantt({ activeTaskId, compact }: GanttProps) {
|
||||
const yearSegments = buildYearSegments()
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||
<div className={cn("min-w-[760px]", compact && "min-w-[680px]")}>
|
||||
{/* Year + month axis */}
|
||||
<div className="grid grid-cols-[180px_1fr] border-b border-border bg-secondary/40">
|
||||
<div className="flex items-end px-4 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Task
|
||||
</div>
|
||||
<div>
|
||||
{/* Years */}
|
||||
<div className="flex">
|
||||
{yearSegments.map((s) => (
|
||||
<div
|
||||
key={s.year}
|
||||
className="border-l border-border py-1.5 text-center text-xs font-semibold text-foreground"
|
||||
style={{ width: `${pct(s.span)}%` }}
|
||||
>
|
||||
{s.year}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Months */}
|
||||
<div className="flex border-t border-border/60">
|
||||
{Array.from({ length: timeline.totalMonths }, (_, i) => {
|
||||
const monthOfYear =
|
||||
((timeline.startMonthOfYear - 1 + i) % 12)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l border-border/40 py-1 text-center text-[10px] text-muted-foreground"
|
||||
style={{ width: `${pct(1)}%` }}
|
||||
>
|
||||
{MONTH_ABBR[monthOfYear][0]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task rows */}
|
||||
<div className="relative">
|
||||
{/* Report vertical lines (over the whole rows area) */}
|
||||
<div className="pointer-events-none absolute inset-0 ml-[180px]">
|
||||
{reports.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="absolute top-0 bottom-0 border-l border-dashed border-brand/50"
|
||||
style={{ left: `${pct(r.month)}%` }}
|
||||
title={r.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tasks.map((t, idx) => {
|
||||
const isActive = activeTaskId === t.id
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[180px_1fr] items-center border-b border-border/60 transition-colors",
|
||||
idx % 2 === 1 && "bg-muted/40",
|
||||
isActive && "bg-brand/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[11px] font-bold",
|
||||
isActive
|
||||
? "bg-brand text-brand-foreground"
|
||||
: "bg-secondary text-primary"
|
||||
)}
|
||||
>
|
||||
{t.id}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xs",
|
||||
isActive
|
||||
? "font-semibold text-foreground"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
title={t.title}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-10">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 h-3.5 -translate-y-1/2 rounded-full transition-all",
|
||||
isActive
|
||||
? "bg-brand shadow-sm ring-2 ring-brand/30"
|
||||
: "bg-primary/70"
|
||||
)}
|
||||
style={{
|
||||
left: `${pct(t.startMonth - 1)}%`,
|
||||
width: `${pct(t.endMonth - t.startMonth + 1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Milestones row */}
|
||||
<div className="grid grid-cols-[180px_1fr] items-center border-b border-border/60">
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Flag className="h-3.5 w-3.5 text-energy" />
|
||||
Milestones
|
||||
</div>
|
||||
<div className="relative h-9">
|
||||
{milestones.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${pct(m.month - 0.5)}%` }}
|
||||
title={`${m.id} · month ${m.month}`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-2.5 w-2.5 rotate-45 rounded-[2px] bg-energy" />
|
||||
<span className="mt-0.5 text-[9px] font-medium text-muted-foreground">
|
||||
{m.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reports row */}
|
||||
<div className="grid grid-cols-[180px_1fr] items-center">
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5 text-brand" />
|
||||
Reports
|
||||
</div>
|
||||
<div className="relative h-9">
|
||||
{reports.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 whitespace-nowrap text-center"
|
||||
style={{ left: `${pct(r.month)}%` }}
|
||||
>
|
||||
<span className="rounded bg-brand/10 px-1.5 py-0.5 text-[9px] font-medium text-brand">
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
Layers,
|
||||
Recycle,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Section } from "@/components/layout/Section"
|
||||
import { goal } from "@/data/project"
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Layers,
|
||||
Recycle,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
}
|
||||
|
||||
export function Goal() {
|
||||
return (
|
||||
<Section
|
||||
id="goal"
|
||||
eyebrow="Goal & contribution"
|
||||
title="Closing the gaps in energy-efficiency assessment"
|
||||
className="bg-background"
|
||||
>
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
{/* Goal text */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="rounded-xl border border-border bg-secondary/50 p-6">
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-brand">
|
||||
The challenge
|
||||
</span>
|
||||
<p className="mt-4 text-lg leading-relaxed text-foreground">
|
||||
{goal.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contribution cards */}
|
||||
<div className="lg:col-span-7">
|
||||
<h3 className="mb-5 text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Our contribution — a decision-support framework
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{goal.contributions.map((c, i) => {
|
||||
const Icon = iconMap[c.icon] ?? Layers
|
||||
return (
|
||||
<motion.div
|
||||
key={c.title}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.45, delay: i * 0.06, ease: "easeOut" }}
|
||||
>
|
||||
<Card className="h-full p-5 transition-all hover:-translate-y-1 hover:border-brand/40 hover:shadow-md">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-brand/10 text-brand">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h4 className="mt-4 font-semibold text-foreground">
|
||||
{c.title}
|
||||
</h4>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{c.description}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowDown, Calendar, Hash, Users, Euro } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { scrollToSection } from "@/lib/scroll"
|
||||
import { project } from "@/data/project"
|
||||
|
||||
const fade = {
|
||||
hidden: { opacity: 0, y: 18 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section
|
||||
id="home"
|
||||
className="relative scroll-mt-20 overflow-hidden bg-primary text-primary-foreground"
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-brand/25 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-24 h-96 w-96 rounded-full bg-energy/15 blur-3xl" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, #fff 1px, transparent 1px), linear-gradient(to bottom, #fff 1px, transparent 1px)",
|
||||
backgroundSize: "48px 48px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-6xl px-5 pb-24 pt-36 md:px-8 md:pb-32 md:pt-44">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
transition={{ staggerChildren: 0.08 }}
|
||||
>
|
||||
<motion.div variants={fade} transition={{ duration: 0.5 }}>
|
||||
<Badge className="bg-white/10 text-primary-foreground backdrop-blur">
|
||||
INESC Coimbra · Research Project
|
||||
</Badge>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-6 text-5xl font-bold leading-[1.05] tracking-tight md:text-7xl"
|
||||
>
|
||||
{project.acronym}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-5 max-w-3xl text-lg font-medium text-primary-foreground/85 md:text-2xl"
|
||||
>
|
||||
{project.title}
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-4 max-w-2xl text-base leading-relaxed text-primary-foreground/70"
|
||||
>
|
||||
{project.tagline}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-8 flex flex-wrap gap-3"
|
||||
>
|
||||
<Button variant="brand" onClick={() => scrollToSection("work-plan")}>
|
||||
View work plan
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => scrollToSection("goal")}
|
||||
className="border-white/25 bg-transparent text-primary-foreground hover:bg-white/10 hover:text-primary-foreground"
|
||||
>
|
||||
About the project
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Meta grid */}
|
||||
<motion.div
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-14 grid gap-px overflow-hidden rounded-xl border border-white/15 bg-white/10 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<MetaCell icon={<Calendar className="h-4 w-4" />} label="Duration">
|
||||
{project.dates.start} → {project.dates.end}
|
||||
</MetaCell>
|
||||
<MetaCell icon={<Hash className="h-4 w-4" />} label="Project code">
|
||||
2023.17768.ICDT
|
||||
</MetaCell>
|
||||
<MetaCell icon={<Euro className="h-4 w-4" />} label="EU support">
|
||||
{project.funding.euSupport}
|
||||
</MetaCell>
|
||||
<MetaCell icon={<Users className="h-4 w-4" />} label="Team">
|
||||
{project.team.length} researchers
|
||||
</MetaCell>
|
||||
</motion.div>
|
||||
|
||||
{/* Team list */}
|
||||
<motion.div
|
||||
variants={fade}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-foreground/55">
|
||||
Project team
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.team.map((member) => (
|
||||
<span
|
||||
key={member}
|
||||
className="rounded-full border border-white/15 bg-white/5 px-3 py-1 text-sm text-primary-foreground/85"
|
||||
>
|
||||
{member}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaCell({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-primary/40 p-5">
|
||||
<div className="flex items-center gap-2 text-primary-foreground/60">
|
||||
{icon}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-primary-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
BookOpen,
|
||||
Presentation,
|
||||
GraduationCap,
|
||||
FileBadge,
|
||||
ArrowUpRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import type { Indicator, IndicatorType } from "@/data/project"
|
||||
|
||||
const typeMeta: Record<
|
||||
IndicatorType,
|
||||
{ label: string; icon: LucideIcon }
|
||||
> = {
|
||||
journal: { label: "Journal", icon: BookOpen },
|
||||
conference: { label: "Conference", icon: Presentation },
|
||||
msc: { label: "MSc Dissertation", icon: GraduationCap },
|
||||
phd: { label: "PhD Thesis", icon: FileBadge },
|
||||
}
|
||||
|
||||
interface IndicatorCardProps {
|
||||
indicator: Indicator
|
||||
index: number
|
||||
onOpen: () => void
|
||||
}
|
||||
|
||||
export function IndicatorCard({ indicator, index, onOpen }: IndicatorCardProps) {
|
||||
const meta = typeMeta[indicator.type]
|
||||
const Icon = meta.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="group block h-full w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
aria-label={`Open details: ${indicator.title}`}
|
||||
>
|
||||
<Card className="flex h-full flex-col p-5 transition-all hover:-translate-y-1 hover:border-brand/40 hover:shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="brand" className="gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{indicator.year && (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{indicator.year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="mt-4 line-clamp-4 font-semibold leading-snug text-foreground">
|
||||
{indicator.title}
|
||||
</h4>
|
||||
{(indicator.authors || indicator.venue) && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||
{[indicator.authors, indicator.venue].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-auto pt-4 inline-flex items-center gap-1 text-sm font-medium text-brand">
|
||||
View details
|
||||
<ArrowUpRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</span>
|
||||
</Card>
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export { typeMeta }
|
||||
@ -0,0 +1,105 @@
|
||||
import { useState } from "react"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
|
||||
import { Section } from "@/components/layout/Section"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
IndicatorCard,
|
||||
typeMeta,
|
||||
} from "@/components/sections/IndicatorCard"
|
||||
import { indicators, type Indicator, type IndicatorType } from "@/data/project"
|
||||
|
||||
const groups: { type: IndicatorType; heading: string }[] = [
|
||||
{ type: "journal", heading: "Journals" },
|
||||
{ type: "conference", heading: "Conferences" },
|
||||
{ type: "msc", heading: "MSc Dissertations" },
|
||||
]
|
||||
|
||||
export function Indicators() {
|
||||
const [open, setOpen] = useState<Indicator | null>(null)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="indicators"
|
||||
eyebrow="Indicators"
|
||||
title="Impact & dissemination"
|
||||
description="Journals, conference papers and dissertations produced within the project. Select any card for full details."
|
||||
className="bg-background"
|
||||
>
|
||||
<div className="space-y-12">
|
||||
{groups.map((g) => {
|
||||
const items = indicators.filter((i) => i.type === g.type)
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div key={g.type}>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{g.heading}
|
||||
</h3>
|
||||
<Badge variant="secondary">{items.length}</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((indicator, i) => (
|
||||
<IndicatorCard
|
||||
key={indicator.id}
|
||||
indicator={indicator}
|
||||
index={i}
|
||||
onOpen={() => setOpen(indicator)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog open={open !== null} onOpenChange={(o) => !o && setOpen(null)}>
|
||||
<DialogContent>
|
||||
{open && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mb-1">
|
||||
<Badge variant="brand">{typeMeta[open.type].label}</Badge>
|
||||
</div>
|
||||
<DialogTitle>{open.title}</DialogTitle>
|
||||
{open.authors && (
|
||||
<DialogDescription>{open.authors}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<dl className="space-y-2 text-sm">
|
||||
{open.venue && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-20 shrink-0 text-muted-foreground">Venue</dt>
|
||||
<dd className="text-foreground">{open.venue}</dd>
|
||||
</div>
|
||||
)}
|
||||
{open.year && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-20 shrink-0 text-muted-foreground">Year</dt>
|
||||
<dd className="text-foreground">{open.year}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{open.url && (
|
||||
<Button variant="brand" asChild className="mt-2 w-fit">
|
||||
<a href={open.url} target="_blank" rel="noopener noreferrer">
|
||||
Open publication
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
brand: "border-transparent bg-brand/10 text-brand",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
energy: "border-transparent bg-energy/10 text-energy",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
brand:
|
||||
"bg-brand text-brand-foreground shadow-sm hover:bg-brand/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-secondary hover:text-secondary-foreground",
|
||||
link: "text-brand underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-lg px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-tight tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@ -0,0 +1,116 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"animate-overlay fixed inset-0 z-50 bg-primary/40 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"animate-content fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-background p-6 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-snug tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
const SheetTrigger = DialogPrimitive.Trigger
|
||||
const SheetClose = DialogPrimitive.Close
|
||||
const SheetPortal = DialogPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"animate-overlay fixed inset-0 z-50 bg-primary/40 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"animate-sheet fixed inset-y-0 right-0 z-50 flex h-full w-3/4 max-w-sm flex-col gap-6 border-l border-border bg-background p-6 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
function SheetHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-2", className)} {...props} />
|
||||
}
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-base font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Design system tokens (INESCC institucional / moderno) ──────────────── */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
--background: #ffffff;
|
||||
--foreground: #0e1a26;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0e1a26;
|
||||
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0e1a26;
|
||||
|
||||
--primary: #0b2a4a; /* navy institucional */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #eef3f8;
|
||||
--secondary-foreground: #0b2a4a;
|
||||
|
||||
--muted: #f5f7fa;
|
||||
--muted-foreground: #5b6b7b;
|
||||
|
||||
--accent: #eef3f8;
|
||||
--accent-foreground: #0b2a4a;
|
||||
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--border: #dce3eb;
|
||||
--input: #dce3eb;
|
||||
--ring: #1e6fd9;
|
||||
|
||||
/* Acentos de marca */
|
||||
--brand: #1e6fd9; /* azul vivo / interação */
|
||||
--brand-foreground: #ffffff;
|
||||
--energy: #2ba66b; /* sustentabilidade */
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-energy: var(--energy);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
/* ── Animations for Radix overlays (sem dependências extra) ─────────────── */
|
||||
@keyframes overlay-in { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes overlay-out { from { opacity: 1 } to { opacity: 0 } }
|
||||
@keyframes content-in { from { opacity: 0; transform: translateY(8px) scale(0.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||
@keyframes content-out { from { opacity: 1 } to { opacity: 0 } }
|
||||
@keyframes sheet-in { from { transform: translateX(100%) } to { transform: translateX(0) } }
|
||||
@keyframes sheet-out { from { transform: translateX(0) } to { transform: translateX(100%) } }
|
||||
|
||||
[data-state="open"].animate-overlay { animation: overlay-in 0.2s ease-out }
|
||||
[data-state="closed"].animate-overlay { animation: overlay-out 0.18s ease-in }
|
||||
[data-state="open"].animate-content { animation: content-in 0.22s ease-out }
|
||||
[data-state="closed"].animate-content { animation: content-out 0.18s ease-in }
|
||||
[data-state="open"].animate-sheet { animation: sheet-in 0.28s cubic-bezier(0.32, 0.72, 0, 1) }
|
||||
[data-state="closed"].animate-sheet { animation: sheet-out 0.24s ease-in }
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
::selection {
|
||||
background-color: color-mix(in srgb, var(--brand) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// Scroll suave para uma âncora de secção (compensado pelo header fixo via scroll-mt).
|
||||
export function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches
|
||||
el.scrollIntoView({
|
||||
behavior: prefersReduced ? "auto" : "smooth",
|
||||
block: "start",
|
||||
})
|
||||
// Atualiza o hash sem dar o salto nativo do browser.
|
||||
history.replaceState(null, "", `#${id}`)
|
||||
}
|
||||
|
||||
export const navItems = [
|
||||
{ id: "home", label: "Home" },
|
||||
{ id: "work-plan", label: "Work plan" },
|
||||
{ id: "indicators", label: "Indicators" },
|
||||
] as const
|
||||
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { RouterProvider } from "@tanstack/react-router"
|
||||
|
||||
import { router } from "@/router"
|
||||
import "@/index.css"
|
||||
|
||||
const rootElement = document.getElementById("root")!
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>
|
||||
)
|
||||
@ -0,0 +1,25 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
} from "@tanstack/react-router"
|
||||
|
||||
import App from "@/App"
|
||||
|
||||
const rootRoute = createRootRoute()
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: App,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||