diff --git a/.github/workflows/sync-md-to-supabase.yml b/.github/workflows/sync-md-to-supabase.yml new file mode 100644 index 00000000..a5726164 --- /dev/null +++ b/.github/workflows/sync-md-to-supabase.yml @@ -0,0 +1,29 @@ +name: Sync Markdown to Supabase + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: npm ci + - name: Sync markdown content + run: npm run sync:supabase + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} diff --git a/.github/workflows/validate-md.yml b/.github/workflows/validate-md.yml new file mode 100644 index 00000000..296219c8 --- /dev/null +++ b/.github/workflows/validate-md.yml @@ -0,0 +1,22 @@ +name: Validate Markdown Frontmatter + +on: + push: + branches: + - main + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: npm ci + - name: Validate frontmatter + run: npm run validate:frontmatter diff --git a/README.md b/README.md index 4c7882f1..80d663c0 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,127 @@ # FACODI — Faculdade Comunitária Digital -**FACODI** é uma plataforma EAD gratuita e open-source inspirada nos planos curriculares da Universidade do Algarve (UALG). -Nosso objetivo é **democratizar o acesso ao ensino superior** por meio de trilhas de estudo organizadas em cursos, unidades curriculares e playlists do YouTube. - -🚀 Projeto mantido pela [Monynha Softwares](https://monynha.com). +FACODI é um portal de ensino superior aberto mantido pela comunidade. Todo o conteúdo editorial é versionado em Markdown e publicado como um site estático em [Hugo](https://gohugo.io) utilizando o tema **Doks**. Uma cópia normalizada das informações vive no banco de dados **Supabase**, que é utilizado para renderizar dados dinâmicos nas páginas de cursos, UCs e tópicos. --- -## ✨ Funcionalidades +## ✨ Principais recursos -- 📚 Catálogo de cursos e currículos completos -- 🎥 Aulas organizadas em **playlists do YouTube** -- 📝 Conteúdo textual em **Markdown versionado** -- 🔑 Autenticação via [Supabase Auth](https://supabase.com) -- 📊 Acompanhamento de progresso por vídeo -- 🌍 Multi-idioma: PT / EN / ES / FR +- 📚 Catálogo de cursos com versões de plano curricular. +- 🧭 Navegação Curso → UC → Tópico com conteúdo em Markdown. +- 🎥 Playlists do YouTube associadas a cada UC/tópico (carregadas do Supabase). +- 🗃️ Sincronização automatizada dos arquivos `.md` para o banco Postgres. +- 🌍 Estrutura preparada para internacionalização (PT padrão, EN fallback). --- - +```bash +# Instalar dependências +npm install -## 📂 Estrutura do Repositório +# Executar site em modo de desenvolvimento +npm run dev -```bash -facodi-docs/ -├─ README.md -├─ .github/ -│ └─ workflows/ -│ ├─ validate-md.yml -│ └─ sync-md-to-supabase.yml -├─ config/ -│ ├─ _default/ -├─ scripts/ -├─ package.json -├─ package-lock.json -├─ content/ -│ ├─ _index.md -│ └─ courses/ -│ └─ LESTI/ -│ └─ 2024-2025/ -│ ├─ index.md -│ └─ uc/ -│ ├─ LESTI-ALG1/ -│ │ ├─ index.md -│ │ └─ estruturas-de-dados.md -│ └─ LESTI-BD1/ -│ └─ index.md -├─ static/ (opcional: imagens anexas ao conteúdo) -│ └─ courses/ -│ └─ ... -└─ schemas/ (opcional: documentação de esquema e seeds) -├─ README.md -├─ mapping.md -└─ examples/ -└─ frontmatter-samples.md -```` +# Gerar build estática +npm run build +``` ---- +Para que os dados dinâmicos apareçam, exporte as variáveis do Supabase antes de iniciar o servidor: -## ⚙️ Como rodar localmente +```bash +export SUPABASE_URL="https://.supabase.co" +export SUPABASE_ANON_KEY="" +``` + +Para executar as rotinas de sincronização ou validar o conteúdo Markdown: ```bash -# Clonar o repositório -git clone https://github.com/Monynha-Softwares/facodi.pt.git -cd facodi.pt +# Validação de frontmatter +npm run validate:frontmatter -# Instalar dependências -pnpm install +# Sincronizar Markdown → Supabase (usa SUPABASE_SERVICE_KEY) +npm run sync:supabase +``` + +--- -# Iniciar Supabase local -pnpm supabase start +## 📂 Estrutura do projeto -# Rodar o frontend -pnpm dev --filter=web +``` +facodi.pt/ +├─ content/ +│ └─ courses/ +│ └─ // +│ ├─ index.md # Metadados do curso +│ └─ uc// +│ ├─ index.md # Metadados da UC +│ └─ .md # Metadados do tópico +├─ layouts/ +│ ├─ _default/baseof.html # Layout base com integração Supabase +│ ├─ home.html # Página inicial (lista de cursos) +│ ├─ course/single.html # Template de curso +│ ├─ uc/single.html # Template de UC +│ └─ topic/single.html # Template de tópico +├─ static/js/ +│ ├─ supabaseClient.js # Instância do cliente Supabase (browser) +│ └─ loaders.js # Funções para carregar cursos/UCs/tópicos +├─ supabase/ +│ ├─ migrations/ # Schemas + RLS +│ └─ seeds/ # Dados de exemplo (LESTI) +└─ .github/workflows/ + ├─ validate-md.yml # Valida frontmatter + └─ sync-md-to-supabase.yml # Publica conteúdo no banco ``` --- -## 🤝 Contribuindo +## 🗄️ Banco de dados Supabase + +Os migrations definem os schemas `catalog`, `subjects` e `mapping`, contendo as tabelas: -FACODI é open-source! Você pode contribuir de várias formas: +- `catalog.course`, `catalog.course_content` +- `catalog.uc`, `catalog.uc_content`, `catalog.uc_learning_outcome` +- `subjects.topic`, `subjects.topic_content`, `subjects.topic_tag` +- `mapping.uc_topic`, `mapping.uc_playlist`, `mapping.topic_playlist` -1. Fork o projeto e abra um Pull Request -2. Relate bugs ou sugira features em [Issues](../../issues) -3. Traduza conteúdos (PT → EN/ES/FR) -4. Ajude a revisar planos curriculares e trilhas de estudo +Todas as tabelas possuem RLS habilitado com política de leitura pública (`role anon`) e escrita restrita ao `service_role`. -Consulte nosso guia em [`CONTRIBUTING.md`](./CONTRIBUTING.md). +### Executando localmente + +```bash +# Inicializar Supabase local +supabase start + +# Aplicar migrations e seeds +supabase db reset +``` + +Após subir o banco, configure as variáveis `SUPABASE_URL`, `SUPABASE_ANON_KEY` e `SUPABASE_SERVICE_KEY` no seu ambiente e/ou nos segredos do GitHub. --- -## 👩‍💻 Autores & Créditos +## 🧪 CI/CD + +Dois workflows acompanham o projeto: -* [Marcelo Santos](https://github.com/marcelosantos) — fundador do projeto -* Comunidade Monynha Softwares -* Base acadêmica: planos curriculares da [UALG](https://www.ualg.pt) +1. **`validate-md.yml`** — Executa `npm run validate:frontmatter` em pushes e pull requests. +2. **`sync-md-to-supabase.yml`** — Publica o conteúdo Markdown no banco utilizando `SUPABASE_SERVICE_KEY`. Pode ser disparado manualmente (`workflow_dispatch`) ou em pushes para `main`. --- -## 📜 Licença +## 🤝 Contribuição + +1. Faça um fork e crie uma branch com sua feature. +2. Garanta que `npm run validate:frontmatter` e `npm run build` estão passando. +3. Abra um Pull Request descrevendo as mudanças. -Este projeto é distribuído sob a licença **MIT**. -Veja o arquivo [`LICENSE`](./LICENSE) para mais detalhes. +Consulte também [`CONTRIBUTING.md`](./CONTRIBUTING.md) para orientações gerais. --- + +## 📜 Licença + +Distribuído sob a licença [MIT](./LICENSE). diff --git a/config/_default/config.toml b/config/_default/config.toml new file mode 100644 index 00000000..fb8e4545 --- /dev/null +++ b/config/_default/config.toml @@ -0,0 +1,25 @@ +baseURL = "https://facodi.pt/" +title = "FACODI — Faculdade Comunitária Digital" +languageCode = "pt-PT" +defaultContentLanguage = "pt" +defaultContentLanguageInSubdir = false + +[languages] + [languages.pt] + languageName = "Português" + contentDir = "content" + weight = 1 + [languages.pt.params] + languageISO = "PT" + languageTag = "pt-PT" + [languages.en] + languageName = "English" + contentDir = "content/en" + weight = 2 + [languages.en.params] + languageISO = "EN" + languageTag = "en-US" + +[security] + [security.funcs] + getenv = ['^HUGO_', '^CI$', '^SUPABASE_'] diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index 274c6a3f..b388ef2c 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -1,21 +1,21 @@ -title = "Monynha Docs" -baseurl = "https://docs.monynha.com/" +title = "FACODI — Faculdade Comunitária Digital" +baseurl = "https://facodi.pt/" disableAliases = true disableHugoGeneratorInject = true # disableKinds = ["taxonomy", "term"] enableEmoji = true enableGitInfo = false enableRobotsTXT = true -languageCode = "en-US" +languageCode = "pt-PT" rssLimit = 10 summarylength = 20 # 70 (default) # Multilingual -defaultContentLanguage = "en" +defaultContentLanguage = "pt" disableLanguages = ["de", "nl"] defaultContentLanguageInSubdir = false -copyRight = "Copyright (c) 2020-2024 Thulite" +copyRight = "Copyright (c) 2025 FACODI" [build.buildStats] enable = true diff --git a/config/_default/languages.toml b/config/_default/languages.toml index 23667cdd..86b10471 100644 --- a/config/_default/languages.toml +++ b/config/_default/languages.toml @@ -1,32 +1,17 @@ +[pt] + languageName = "Português" + contentDir = "content" + weight = 10 + [pt.params] + languageISO = "PT" + languageTag = "pt-PT" + footer = 'Conteúdo distribuído pela comunidade FACODI.' + [en] languageName = "English" contentDir = "content/en" - weight = 10 + weight = 20 [en.params] languageISO = "EN" languageTag = "en-US" - footer = 'Brought to you by Thulite' - alertText = 'Doks version 1.0 just shipped!' - -[de] - languageName = "German" - contentDir = "content/de" - weight = 15 - [de.params] - languageISO = "DE" - languageTag = "de-DE" - footer = 'Gebaut mit Thulite' - alertText = 'Neue Version ist da! Doks v0.5' - -[nl] - languageName = "Nederlands" - contentDir = "content/nl" - weight = 20 - [nl.params] - languageISO = "NL" - languageTag = "nl-NL" - titleAddition = "Modern documentatie-thema" - description = "Doks is een Hugo-thema waarmee je moderne documentatie-websites kunt bouwen die veilig, snel en klaar voor SEO zijn — standaard." - titleHome = "Doks thema" - footer = 'Mogelijk gemaakt door Netlify, Hugo, en Doks' - alertText = 'Introductie van het Doks-kinderthema, verschillende DX + UX-updates en meer! Bekijk Doks v0.2' + footer = 'Content provided by the FACODI community.' diff --git a/config/_default/params.toml b/config/_default/params.toml index ddfffddb..650a1456 100644 --- a/config/_default/params.toml +++ b/config/_default/params.toml @@ -1,13 +1,17 @@ # Hugo -title = "Monynha Docs" -description = "Get to know more about our awesome projects and awesome open source resources!" +title = "FACODI — Faculdade Comunitária Digital" +description = "Portal comunitário que conecta planos curriculares oficiais a playlists abertas de estudo." images = ["cover.png"] # mainSections -mainSections = ["docs"] +mainSections = ["courses"] + +[supabase] + url = "" + anonKey = "" [social] - twitter = "getdoks" + twitter = "facodi" # Doks (@thulite/doks-core) [doks] @@ -21,11 +25,11 @@ mainSections = ["docs"] ## Button navBarButton = false # false (default) or true - navBarButtonUrl = "/docs/prologue/introduction/" - navBarButtonText = "Get started" + navBarButtonUrl = "/courses/" + navBarButtonText = "Catálogo" # FlexSearch - flexSearch = true # true (default) or false + flexSearch = false # true (default) or false searchExclKinds = [] # list of page kinds to exclude from search indexing (e.g. ["home", "taxonomy", "term"] ) searchExclTypes = [] # list of content types to exclude from search indexing (e.g. ["blog", "docs", "legal", "contributors", "categories"]) showSearch = [] # [] (all pages, default) or homepage (optionally) and list of sections (e.g. ["homepage", "blog", "guides"]) @@ -44,14 +48,14 @@ mainSections = ["docs"] bootstrapJavascript = false # false (default) or true # Nav - sectionNav = ["docs"] # ["docs"] (default) or list of sections (e.g. ["docs", "guides"]) + sectionNav = ["courses"] # ["docs"] (default) or list of sections (e.g. ["docs", "guides"]) toTopButton = false # false (default) or true breadcrumbTrail = false # false (default) or true headlineHash = true # true (default) or false scrollSpy = true # true (default) or false # Multilingual - multilingualMode = false # false (default) or true + multilingualMode = true # false (default) or true showMissingLanguages = true # whether or not to show untranslated languages in the language menu; true (default) or false # Versioning @@ -131,8 +135,8 @@ mainSections = ["docs"] [seo.schemas] type = "Organization" # Organization (default) or Person logo = "favicon-512x512.png" # Logo of Organization — favicon-512x512.png (default) - name = "Thulite" # Name of Organization or Person - sameAs = [] # E.g. ["https://github.com/thuliteio/thulite", "https://fosstodon.org/@thulite"] + name = "FACODI" # Name of Organization or Person + sameAs = [] # E.g. ["https://github.com/facodi"] images = ["cover.png"] # ["cover.png"] (default) article = [] # Article sections newsArticle = [] # NewsArticle sections diff --git a/config/next/hugo.toml b/config/next/hugo.toml deleted file mode 100644 index 8d1b518e..00000000 --- a/config/next/hugo.toml +++ /dev/null @@ -1,2 +0,0 @@ -# Overrides for next environment -baseurl = "/" diff --git a/content/_index.md b/content/_index.md index 6e64711d..4ab5a5ac 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,13 +1,6 @@ --- -title: "Welcome to Doks" -description: "" -lead: "Congrats on setting up a new Doks project!" -date: 2023-09-07T16:33:54+02:00 -lastmod: 2023-09-07T16:33:54+02:00 -draft: false -seo: - title: "Welcome to Doks" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true +title: "FACODI" +description: "Portal comunitário digital" +lead: "Trilhas oficiais de ensino superior com playlists abertas e conteúdo editável em Markdown." +layout: "home" --- diff --git a/content/blog/_index.md b/content/blog/_index.md deleted file mode 100644 index 4cd6946e..00000000 --- a/content/blog/_index.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: "Blog" -description: "" -summary: "" -date: 2023-09-07T16:21:44+02:00 -lastmod: 2023-09-07T16:21:44+02:00 -draft: false -weight: 50 -categories: [] -tags: [] -contributors: [] -pinned: false -homepage: false -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- diff --git a/content/blog/example/index.md b/content/blog/example/index.md deleted file mode 100644 index c3ccae4a..00000000 --- a/content/blog/example/index.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: "Example Post" -description: "Just an example post." -summary: "You can use blog posts for announcing product updates and features." -date: 2023-09-07T16:27:22+02:00 -lastmod: 2023-09-07T16:27:22+02:00 -draft: false -weight: 50 -categories: [] -tags: [] -contributors: [] -pinned: false -homepage: false -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- - -Well-thought-through product announcements will help increase feature awareness and engage users with new functionality. Just like [sharing your public roadmap](https://canny.io/blog/should-you-have-a-public-roadmap/), it's also a great way to let potential customers see that you're constantly improving. - -## Further reading - -- Read [How to announce product updates and features](https://canny.io/blog/announce-product-updates-features/) diff --git a/content/courses/_index.md b/content/courses/_index.md new file mode 100644 index 00000000..54f8f1e7 --- /dev/null +++ b/content/courses/_index.md @@ -0,0 +1,4 @@ +--- +title: "Cursos" +summary: "Explore os cursos catalogados pela comunidade FACODI." +--- diff --git a/content/courses/lesti/2024-2025/index.md b/content/courses/lesti/2024-2025/index.md new file mode 100644 index 00000000..94fb696f --- /dev/null +++ b/content/courses/lesti/2024-2025/index.md @@ -0,0 +1,15 @@ +--- +title: "Licenciatura em Engenharia de Software e Tecnologia de Informação" +code: "LESTI" +plan_version: "2024/2025" +degree: "bachelor" +ects_total: 180 +duration_semesters: 6 +institution: "Universidade do Algarve" +school: "Escola Superior de Tecnologia" +language: "pt" +summary: "Curso focado em formar profissionais capazes de projetar, implementar e manter sistemas de software com foco em impacto social." +layout: "course" +type: "course" +--- +O plano curricular foi adaptado para oferecer uma experiência aberta e colaborativa. Cada unidade curricular possui playlists, tópicos e resultados de aprendizagem acompanhados por voluntários da comunidade. diff --git a/content/courses/lesti/2024-2025/uc/lesti-alg1/estruturas-de-dados.md b/content/courses/lesti/2024-2025/uc/lesti-alg1/estruturas-de-dados.md new file mode 100644 index 00000000..b64bc06e --- /dev/null +++ b/content/courses/lesti/2024-2025/uc/lesti-alg1/estruturas-de-dados.md @@ -0,0 +1,14 @@ +--- +title: "Estruturas de Dados" +slug: "estruturas-de-dados" +summary: "Introdução a listas, pilhas, filas e árvores para organizar informação." +tags: + - "algoritmos" + - "estrutura-de-dados" +youtube_playlists: + - id: "PL987654321TOPICO" + priority: 1 +layout: "topic" +type: "topic" +--- +Aprofundamos listas ligadas, pilhas, filas e árvores binárias, sempre relacionando a escolha da estrutura ao tipo de problema a ser resolvido. diff --git a/content/courses/lesti/2024-2025/uc/lesti-alg1/index.md b/content/courses/lesti/2024-2025/uc/lesti-alg1/index.md new file mode 100644 index 00000000..5ab697c4 --- /dev/null +++ b/content/courses/lesti/2024-2025/uc/lesti-alg1/index.md @@ -0,0 +1,20 @@ +--- +title: "Algoritmos I" +code: "LESTI-ALG1" +course_code: "LESTI" +plan_version: "2024/2025" +ects: 6 +semester: 1 +language: "pt" +prerequisites: [] +learning_outcomes: + - "Aplicar princípios de análise de algoritmos para resolver problemas estruturados." + - "Comparar estruturas de dados básicas identificando vantagens e limitações." + - "Implementar algoritmos iterativos e recursivos utilizando boas práticas de código." +youtube_playlists: + - id: "PL123456789ALG1" + priority: 1 +layout: "uc" +type: "uc" +--- +Esta unidade curricular introduz fundamentos de algoritmos, estruturando a aprendizagem com foco em resolução de problemas e uso de linguagens de programação de alto nível. diff --git a/content/docs/_index.md b/content/docs/_index.md deleted file mode 100644 index 5ec013c8..00000000 --- a/content/docs/_index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: "Docs" -description: "" -summary: "" -date: 2023-09-07T16:12:03+02:00 -lastmod: 2023-09-07T16:12:03+02:00 -draft: false -weight: 999 -toc: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- diff --git a/content/docs/guides/_index.md b/content/docs/guides/_index.md deleted file mode 100644 index 2c26cb64..00000000 --- a/content/docs/guides/_index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: "Guides" -description: "" -summary: "" -date: 2023-09-07T16:06:50+02:00 -lastmod: 2023-09-07T16:06:50+02:00 -draft: false -weight: 800 -toc: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- diff --git a/content/docs/guides/example.md b/content/docs/guides/example.md deleted file mode 100644 index 3258d103..00000000 --- a/content/docs/guides/example.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "Example Guide" -description: "Guides lead a user through a specific task they want to accomplish, often with a sequence of steps." -summary: "" -date: 2023-09-07T16:04:48+02:00 -lastmod: 2023-09-07T16:04:48+02:00 -draft: false -weight: 810 -toc: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- - -Guides lead a user through a specific task they want to accomplish, often with a sequence of steps. Writing a good guide requires thinking about what your users are trying to do. - -## Further reading - -- Read [about how-to guides](https://diataxis.fr/how-to-guides/) in the Diátaxis framework diff --git a/content/docs/reference/_index.md b/content/docs/reference/_index.md deleted file mode 100644 index 15e9941e..00000000 --- a/content/docs/reference/_index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "Reference" -description: "" -summary: "" -date: 2023-09-07T16:12:37+02:00 -lastmod: 2023-09-07T16:12:37+02:00 -draft: false -weight: 900 -toc: true -sidebar: - collapsed: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- diff --git a/content/docs/reference/example.md b/content/docs/reference/example.md deleted file mode 100644 index fe5d9b69..00000000 --- a/content/docs/reference/example.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "Example Reference" -description: "Reference pages are ideal for outlining how things work in terse and clear terms." -summary: "" -date: 2023-09-07T16:13:18+02:00 -lastmod: 2023-09-07T16:13:18+02:00 -draft: false -weight: 910 -toc: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- - -Reference pages are ideal for outlining how things work in terse and clear terms. Less concerned with telling a story or addressing a specific use case, they should give a comprehensive outline of what your documenting. - -## Further reading - -- Read [about reference](https://diataxis.fr/reference/) in the Diátaxis framework diff --git a/content/docs/resources.md b/content/docs/resources.md deleted file mode 100644 index a5dac925..00000000 --- a/content/docs/resources.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "Resources" -description: "" -summary: "" -date: 2024-02-27T09:30:56+01:00 -lastmod: 2024-02-27T09:30:56+01:00 -draft: false -weight: 999 -toc: true -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- - -Link to valuable, relevant resources. diff --git a/content/en/_index.md b/content/en/_index.md new file mode 100644 index 00000000..6c6c5179 --- /dev/null +++ b/content/en/_index.md @@ -0,0 +1,7 @@ +--- +title: "FACODI" +description: "Digital community college" +lead: "Multilingual catalog of open study paths." +layout: "home" +draft: true +--- diff --git a/content/privacy.md b/content/privacy.md deleted file mode 100644 index 178d9c15..00000000 --- a/content/privacy.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: "Privacy Policy" -description: "" -summary: "" -date: 2023-09-07T17:19:07+02:00 -lastmod: 2023-09-07T17:19:07+02:00 -draft: false -type: "legal" -seo: - title: "" # custom title (optional) - description: "" # custom description (recommended) - canonical: "" # custom canonical URL (optional) - noindex: false # false (default) or true ---- diff --git a/hugo_stats.json b/hugo_stats.json index 4b9a90e3..5e174b5b 100644 --- a/hugo_stats.json +++ b/hugo_stats.json @@ -7,27 +7,22 @@ "base", "body", "button", - "circle", "code", - "details", + "dd", "div", + "dl", + "dt", "footer", - "form", - "g", "h1", "h2", - "h3", "h5", "head", "header", + "hr", "html", - "input", - "kbd", - "label", "li", "line", "link", - "main", "meta", "nav", "noscript", @@ -37,124 +32,97 @@ "section", "small", "span", - "strong", "style", - "summary", "svg", - "template", - "time", "title", "ul" ], "classes": [ - "DocSearch-Label", - "active", - "anchor", - "bg-dots", - "blog", - "blog-header", + "alert", + "alert-info", + "alert-secondary", + "bg-light", + "border-0", "btn", - "btn-close", - "btn-cta", - "btn-lg", + "btn-dropdown", "btn-link", - "btn-primary", "card", "card-body", "card-list", "categories", + "col-12", "col-lg-10", - "col-lg-11", - "col-lg-12", - "col-lg-5", + "col-lg-4", "col-lg-8", - "col-lg-9", "col-md-12", - "col-xl-3", - "col-xl-4", + "col-md-6", + "col-sm-6", "col-xl-8", - "col-xl-9", "container", - "container-fluid", "container-lg", "content", "contributors", - "created-date", + "course-page", + "courses", + "current", "d-flex", - "d-lg-block", "d-lg-none", - "d-md-block", - "d-md-none", - "d-none", - "d-xl-block", - "d-xl-none", - "docs", - "docs-content", - "docs-links", - "docs-sidebar", - "docs-toc", + "display-4", + "display-5", "doks-sidebar", + "dropdown", + "dropdown-caret", + "dropdown-divider", + "dropdown-item", + "dropdown-menu", + "dropdown-menu-lg-end", + "dropdown-toggle", "error404", "flex-column", + "flex-fill", "flex-grow-1", "flex-lg-row", - "flex-md-row", "flex-row", - "flex-sm-row", - "flex-xl-nowrap", "footer", - "form-control", - "form-control-lg", "fs-5", + "fs-6", + "fw-semibold", + "g-2", + "g-4", + "h-100", "h-auto", "h3", - "h4", "h5", "home", "icon", "icon-tabler", - "icon-tabler-arrow-left", - "icon-tabler-arrow-right", "icon-tabler-brand-github", - "icon-tabler-clock", + "icon-tabler-chevron-down", "icon-tabler-dots-vertical", + "icon-tabler-language", "icon-tabler-menu", "icon-tabler-moon", - "icon-tabler-search", "icon-tabler-sun", "icon-tabler-x", "justify-content-between", "justify-content-center", - "justify-content-end", - "justify-content-start", "lead", "list", "list-inline", "list-inline-item", - "list-nested", - "list-unstyled", - "list-view", - "m-2", + "markdown", "mb-0", - "me-2", + "mb-2", + "mb-3", "me-auto", "me-lg-1", + "me-lg-2", "me-lg-3", - "message", - "modal", - "modal-body", - "modal-content", - "modal-dialog", - "modal-dialog-scrollable", - "modal-footer", - "modal-fullscreen-md-down", - "modal-header", - "modal-title", "ms-2", - "ms-3", "ms-auto", "ms-lg-2", - "mt-3", + "mt-1", + "mt-4", "mt-n3", "mx-2", "mx-auto", @@ -173,75 +141,45 @@ "offcanvas-start", "offcanvas-title", "order-3", + "order-lg-2", "order-lg-4", "p-0", - "p-2", - "page-footer-meta", - "page-links", - "page-nav", - "pb-3", - "position-relative", - "privacy", - "px-0", - "query-no-results", - "reading-time", - "rounded-pill", + "py-5", + "rounded", "row", - "search-form", - "search-input", - "search-loading", - "search-no-recent", - "search-no-results", - "search-result", - "search-results", - "search-text", "section", - "section-features", - "section-md", - "section-nav", + "shadow", + "shadow-sm", "single", + "small", "social-link", - "status", "sticky-top", "stretched-link", - "submitted", "tags", "taxonomy", - "text-body", - "text-body-secondary", "text-center", "text-decoration-none", - "text-end", "text-lg-end", "text-lg-start", "text-muted", - "text-nowrap", - "text-reset", - "title", - "title-submitted", - "toc-mobile", + "text-uppercase", + "untranslated", "visually-hidden", - "w-100", "wrap" ], "ids": [ - "TableOfContents", "buttonColorMode", + "course-content", + "course-uc-list", + "course-ucs", "doks-docs-nav", - "further-reading", + "doks-language-current", + "doks-languages", "offcanvasNavMain", "offcanvasNavMainLabel", "offcanvasNavSection", "offcanvasNavSectionLabel", - "query", - "search-form", - "searchModal", - "searchModalLabel", - "searchResults", - "searchToggleDesktop", - "searchToggleMobile", - "socialMenu", - "toc" + "socialMenu" ] } } diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html new file mode 100644 index 00000000..38e15e23 --- /dev/null +++ b/layouts/_default/baseof.html @@ -0,0 +1,48 @@ + + + {{ partial "head/head" . }} + {{ partial "head/body-class" . }} + + {{ partial "header/header" . }} +
+
+ {{ if and (eq site.Params.doks.containerBreakpoint "fluid") (or (not (in .Site.Params.mainSections .Type)) (.IsNode)) }}
{{ end }} + {{ block "main" . }}{{ end }} + {{ if and (eq site.Params.doks.containerBreakpoint "fluid") (or (not (in .Site.Params.mainSections .Type)) (.IsNode)) }}
{{ end }} +
+
+ {{ block "sidebar-prefooter" . }}{{ end }} + {{ block "sidebar-footer" . }}{{ end }} + {{ partial "footer/footer" . }} + {{ partial "footer/script-footer" . }} + {{ if eq site.Params.doks.toTopButton true -}} + {{ partial "footer/to-top" . }} + {{ end }} + {{ $supabaseUrl := (or (getenv "SUPABASE_URL") site.Params.supabase.url) }} + {{ $supabaseAnon := (or (getenv "SUPABASE_ANON_KEY") site.Params.supabase.anonKey) }} + + + {{ block "page_scripts" . }}{{ end }} + + + diff --git a/layouts/course/single.html b/layouts/course/single.html new file mode 100644 index 00000000..5f1d2753 --- /dev/null +++ b/layouts/course/single.html @@ -0,0 +1,73 @@ +{{ define "main" }} +
+
+
+

{{ .Title }}

+ {{ with .Params.summary }} +

{{ . }}

+ {{ end }} +
+
+
Código
+
{{ .Params.code }}
+
+
+
Versão
+
{{ .Params.plan_version }}
+
+
+
Grau
+
—
+
+
+
ECTS
+
—
+
+
+
Semestres
+
—
+
+
+
Idioma
+
—
+
+
+
Instituição
+
—
+
+
+
Escola
+
—
+
+
+
+

Carregando ementa do curso…

+
+
+
+
+
+
+
+
+

Unidades Curriculares

+

Conteúdo dinâmico carregado do Supabase.

+
+
+
+
+
Aguardando dados das UCs…
+
+
+
+
+{{ end }} + +{{ define "sidebar-prefooter" }}{{ end }} +{{ define "sidebar-footer" }}{{ end }} + +{{ define "body_attributes" }} + {{ with .Params.code }} data-course="{{ . }}"{{ end }} + {{ with .Params.plan_version }} data-plan-version="{{ . }}"{{ end }} + data-course-path="{{ .RelPermalink }}" +{{ end }} diff --git a/layouts/home.html b/layouts/home.html index d95a4a74..b3bbf2b0 100644 --- a/layouts/home.html +++ b/layouts/home.html @@ -1,59 +1,52 @@ {{ define "main" }} -
-
-
-

{{ .Title }}

-
-
-

{{ .Params.lead | safeHTML }}

- {{ i18n "get_started" }} - {{ .Content }} +
+
+
+

{{ .Title }}

+ {{ with .Params.lead }} +

{{ . }}

+ {{ end }}
-
-{{ end }} - -{{ define "sidebar-prefooter" }} - {{ if site.Params.doks.backgroundDots -}} -
-
-
- {{ end -}} - {{ if eq $.Site.Language.LanguageName "English" }} -
-
-
-
-

Update content

-

Edit content/_index.md to see this page change.

-
-
-

Add new content

-

Add Markdown files to content to create new pages.

-
-
-

Configure your site

-

Edit your config in config/_default/params.toml.

-
-
-

Read the docs

-

Learn more in the Docs.

-
+ {{ $courses := where site.RegularPages "Type" "course" }} +
+ {{ if gt (len $courses) 0 }} + {{ range sort $courses "Params.plan_version" "desc" }} +
+
+
+

+ {{ .Title }} +

+ {{ with .Params.summary }} +

{{ . }}

+ {{ end }} +
+
+
Plano
+
{{ .Params.plan_version }}
+
+
+
ECTS
+
{{ .Params.ects_total }}
+
+
+
Semestres
+
{{ .Params.duration_semesters }}
+
+
+
+
+ {{ end }} + {{ else }} +
+
Nenhum curso cadastrado ainda. Adicione arquivos em content/courses/ para começar.
-
- {{ end }} -{{ end }} - -{{ define "sidebar-footer" }} -{{ if site.Params.doks.sectionFooter -}} -
-
-
-

Start building with Doks today

- {{ i18n "get-started" }} -
+ {{ end }}
-{{ end -}} {{ end }} + +{{ define "sidebar-prefooter" }}{{ end }} +{{ define "sidebar-footer" }}{{ end }} diff --git a/layouts/topic/single.html b/layouts/topic/single.html new file mode 100644 index 00000000..f0aaf5f4 --- /dev/null +++ b/layouts/topic/single.html @@ -0,0 +1,46 @@ +{{ define "main" }} +
+
+
+

{{ .Title }}

+ {{ with .Params.summary }} +

{{ . }}

+ {{ end }} +
+

Carregando conteúdo do tópico…

+
+
+
+
+
+
+
+
+

Playlists Sugeridas

+
+
+ Nenhuma playlist carregada ainda. +
+
+
+
+
+
+
+
+
+

Tags

+
    +
  • Carregando tags…
  • +
+
+
+
+{{ end }} + +{{ define "sidebar-prefooter" }}{{ end }} +{{ define "sidebar-footer" }}{{ end }} + +{{ define "body_attributes" }} + {{ with .Params.slug }} data-topic="{{ . }}"{{ end }} +{{ end }} diff --git a/layouts/uc/single.html b/layouts/uc/single.html new file mode 100644 index 00000000..d6f8d125 --- /dev/null +++ b/layouts/uc/single.html @@ -0,0 +1,82 @@ +{{ define "main" }} +
+
+
+

{{ .Title }}

+
+
+
Código
+
{{ .Params.code }}
+
+
+
Semestre
+
{{ .Params.semester }}
+
+
+
ECTS
+
{{ .Params.ects }}
+
+
+
Idioma
+
{{ .Params.language }}
+
+
+
Pré-requisitos
+
—
+
+
+
+

Carregando conteúdo da UC…

+
+
+
+
+
+
+
+
+

Resultados de Aprendizagem

+
    +
  1. Carregando resultados…
  2. +
+
+
+
+
+
+
+
+

Playlists Recomendadas

+
+
+ Aguardando playlists… +
+
+
+
+
+
+
+
+
+

Tópicos

+
+
+
Os tópicos serão exibidos aqui assim que carregados.
+
+
+
+
+
+
+{{ end }} + +{{ define "sidebar-prefooter" }}{{ end }} +{{ define "sidebar-footer" }}{{ end }} + +{{ define "body_attributes" }} + {{ with .Params.code }} data-uc="{{ . }}"{{ end }} + {{ with .Params.course_code }} data-course="{{ . }}"{{ end }} + {{ with .Params.plan_version }} data-plan-version="{{ . }}"{{ end }} + data-uc-path="{{ .RelPermalink }}" +{{ end }} diff --git a/package-lock.json b/package-lock.json index 35f14326..9c011812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@supabase/supabase-js": "^2.48.0", "@tabler/icons": "^3.34.1", "@thulite/doks-core": "^1.8.3", "@thulite/images": "^3.3.1", @@ -20,6 +21,8 @@ "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.5", + "fast-glob": "^3.3.2", + "gray-matter": "^4.0.3", "prettier": "^3.6.2", "vite": "^7.0.6" }, @@ -2902,6 +2905,80 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.6.tgz", + "integrity": "sha512-bhjZ7rmxAibjgmzTmQBxJU6ZIBCCJTc3Uwgvdi4FewueUTAGO5hxZT1Sj6tiD+0dSXf9XI87BDdJrg12z8Uaew==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz", + "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.5", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz", + "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.1.tgz", + "integrity": "sha512-QWg3HV6Db2J81VQx0PqLq0JDBn4Q8B1FYn1kYcbla8+d5WDmTdwwMr+EJAxNOSs9W4mhKMv+EYCpCrTFlTj4VQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.57.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz", + "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.6", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.21.4", + "@supabase/realtime-js": "2.15.5", + "@supabase/storage-js": "2.12.1" + } + }, "node_modules/@tabler/icons": { "version": "3.34.1", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.1.tgz", @@ -2999,14 +3076,26 @@ "version": "24.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "undici-types": "~7.10.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -3629,6 +3718,19 @@ "node": ">=0.10.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -3976,6 +4078,22 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4074,6 +4192,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4211,6 +4339,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazysizes": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/lazysizes/-/lazysizes-5.3.2.tgz", @@ -5246,6 +5384,20 @@ "node": ">=6.0.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -5405,6 +5557,16 @@ "node": ">=4" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -5524,17 +5686,13 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -5732,14 +5890,12 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -5808,6 +5964,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 134e4fc8..c9860907 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,16 @@ }, "scripts": { "create": "hugo new", - "dev": "hugo server --disableFastRender --noHTTPCache", - "start": "hugo server --disableFastRender --noHTTPCache --port 3000", + "dev": "hugo server -D", + "start": "hugo server -D --port 3000", "format": "prettier **/** -w -c", - "build": "hugo --minify --gc", - "preview": "vite preview --outDir public", + "build": "hugo", + "validate:frontmatter": "node scripts/validate-frontmatter.mjs", + "sync:supabase": "node scripts/sync-markdown-to-supabase.mjs", "postinstall": "bash scripts/install-hugo.sh" }, "dependencies": { + "@supabase/supabase-js": "^2.48.0", "@thulite/doks-core": "^1.8.3", "@thulite/images": "^3.3.1", "@thulite/inline-svg": "^1.2.0", @@ -28,6 +30,8 @@ "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.5", + "fast-glob": "^3.3.2", + "gray-matter": "^4.0.3", "prettier": "^3.6.2", "vite": "^7.0.6" }, diff --git a/scripts/sync-markdown-to-supabase.mjs b/scripts/sync-markdown-to-supabase.mjs new file mode 100644 index 00000000..2a2be069 --- /dev/null +++ b/scripts/sync-markdown-to-supabase.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import fg from "fast-glob"; +import matter from "gray-matter"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const serviceKey = process.env.SUPABASE_SERVICE_KEY; + +if (!supabaseUrl || !serviceKey) { + console.error("Defina SUPABASE_URL e SUPABASE_SERVICE_KEY para sincronizar o conteúdo."); + process.exit(1); +} + +const client = createClient(supabaseUrl, serviceKey, { auth: { persistSession: false } }); +const contentRoot = path.join(process.cwd(), "content", "courses"); + +const courses = []; +const courseContents = []; +const ucs = []; +const ucContents = []; +const ucOutcomes = []; +const ucPlaylists = []; +const ucTopics = []; +const topics = []; +const topicContents = []; +const topicTags = []; +const topicPlaylists = []; + +const ucDirMap = new Map(); + +function assertArray(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeContent(content) { + return content.trim(); +} + +async function loadIndexFiles() { + const files = await fg(["**/index.md"], { cwd: contentRoot, dot: false }); + for (const relativeFile of files) { + const normalized = relativeFile.replace(/\\/g, "/"); + const fullPath = path.join(contentRoot, relativeFile); + const raw = await readFile(fullPath, "utf8"); + const { data, content } = matter(raw); + if (normalized.includes("/uc/")) { + const ucData = { + code: data.code, + course_code: data.course_code, + plan_version: data.plan_version, + name: data.title, + description: data.description ?? data.summary ?? "", + ects: data.ects ?? null, + semester: data.semester ?? null, + language: data.language ?? null, + prerequisites: assertArray(data.prerequisites ?? []), + }; + ucs.push(ucData); + ucContents.push({ uc_code: data.code, content_md: normalizeContent(content) }); + const dir = path.posix.dirname(normalized); + ucDirMap.set(dir, { + code: data.code, + course_code: data.course_code, + plan_version: data.plan_version, + }); + assertArray(data.learning_outcomes).forEach((outcome, index) => { + ucOutcomes.push({ uc_code: data.code, outcome, order: index + 1 }); + }); + assertArray(data.youtube_playlists).forEach((playlist) => { + if (playlist?.id) { + ucPlaylists.push({ uc_code: data.code, playlist_id: playlist.id, priority: playlist.priority ?? null }); + } + }); + } else { + const courseData = { + code: data.code, + plan_version: data.plan_version, + name: data.title, + degree: data.degree ?? null, + ects_total: data.ects_total ?? null, + duration_semesters: data.duration_semesters ?? null, + institution: data.institution ?? null, + school: data.school ?? null, + language: data.language ?? null, + summary: data.summary ?? "", + }; + courses.push(courseData); + courseContents.push({ course_code: data.code, plan_version: data.plan_version, content_md: normalizeContent(content) }); + } + } +} + +async function loadTopicFiles() { + const files = await fg(["**/*.md", "!**/index.md"], { cwd: contentRoot, dot: false }); + for (const relativeFile of files) { + const normalized = relativeFile.replace(/\\/g, "/"); + const fullPath = path.join(contentRoot, relativeFile); + const raw = await readFile(fullPath, "utf8"); + const { data, content } = matter(raw); + const ucDir = path.posix.dirname(normalized); + const ucInfo = ucDirMap.get(ucDir); + if (!ucInfo) { + console.warn(`Ignorando tópico ${normalized}: UC associada não encontrada.`); + continue; + } + topics.push({ slug: data.slug, name: data.title, summary: data.summary ?? "" }); + topicContents.push({ topic_slug: data.slug, content_md: normalizeContent(content) }); + assertArray(data.tags).forEach((tag) => { + topicTags.push({ topic_slug: data.slug, tag }); + }); + assertArray(data.youtube_playlists).forEach((playlist) => { + if (playlist?.id) { + topicPlaylists.push({ topic_slug: data.slug, playlist_id: playlist.id, priority: playlist.priority ?? null }); + } + }); + ucTopics.push({ uc_code: ucInfo.code, topic_slug: data.slug }); + } +} + +function groupBy(items, key) { + return items.reduce((acc, item) => { + const groupKey = item[key]; + if (!groupKey) return acc; + if (!acc[groupKey]) acc[groupKey] = []; + acc[groupKey].push(item); + return acc; + }, {}); +} + +async function replaceRows(table, keyField, groups) { + for (const [key, rows] of Object.entries(groups)) { + await client.from(table).delete().eq(keyField, key); + if (rows.length > 0) { + const { error } = await client.from(table).insert(rows); + if (error) throw error; + } + } +} + +async function sync() { + await loadIndexFiles(); + await loadTopicFiles(); + + if (courses.length > 0) { + const { error } = await client.from("catalog.course").upsert(courses, { onConflict: "code,plan_version" }); + if (error) throw error; + } + if (courseContents.length > 0) { + const { error } = await client + .from("catalog.course_content") + .upsert(courseContents, { onConflict: "course_code,plan_version" }); + if (error) throw error; + } + if (ucs.length > 0) { + const { error } = await client.from("catalog.uc").upsert(ucs, { onConflict: "code" }); + if (error) throw error; + } + if (ucContents.length > 0) { + const { error } = await client.from("catalog.uc_content").upsert(ucContents, { onConflict: "uc_code" }); + if (error) throw error; + } + if (topics.length > 0) { + const { error } = await client.from("subjects.topic").upsert(topics, { onConflict: "slug" }); + if (error) throw error; + } + if (topicContents.length > 0) { + const { error } = await client.from("subjects.topic_content").upsert(topicContents, { onConflict: "topic_slug" }); + if (error) throw error; + } + + await replaceRows("catalog.uc_learning_outcome", "uc_code", groupBy(ucOutcomes, "uc_code")); + await replaceRows("mapping.uc_playlist", "uc_code", groupBy(ucPlaylists, "uc_code")); + await replaceRows("mapping.uc_topic", "uc_code", groupBy(ucTopics, "uc_code")); + await replaceRows("subjects.topic_tag", "topic_slug", groupBy(topicTags, "topic_slug")); + await replaceRows("mapping.topic_playlist", "topic_slug", groupBy(topicPlaylists, "topic_slug")); + + console.log("Sincronização concluída com sucesso."); +} + +sync().catch((error) => { + console.error("Erro ao sincronizar conteúdo com o Supabase:", error); + process.exitCode = 1; +}); diff --git a/scripts/validate-frontmatter.mjs b/scripts/validate-frontmatter.mjs new file mode 100644 index 00000000..7ab7afd8 --- /dev/null +++ b/scripts/validate-frontmatter.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import fg from "fast-glob"; +import matter from "gray-matter"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const contentRoot = path.join(process.cwd(), "content", "courses"); + +function assertArray(value) { + return Array.isArray(value) ? value : []; +} + +function validatePlaylists(items, context) { + const errors = []; + items.forEach((item, idx) => { + if (typeof item?.id !== "string" || item.id.trim() === "") { + errors.push(`${context}: playlist[${idx}] precisa de um campo 'id' (string).`); + } + if (item?.priority != null && Number.isNaN(Number(item.priority))) { + errors.push(`${context}: playlist[${idx}] possui 'priority' inválido.`); + } + }); + return errors; +} + +async function main() { + const files = await fg(["**/*.md"], { cwd: contentRoot, dot: false }); + const errors = []; + + for (const relativeFile of files) { + const fullPath = path.join(contentRoot, relativeFile); + const raw = await readFile(fullPath, "utf8"); + const { data } = matter(raw); + const normalized = relativeFile.replace(/\\/g, "/"); + const baseName = path.posix.basename(normalized); + + if (baseName.startsWith("_")) { + continue; + } + + if (normalized.endsWith("index.md")) { + if (normalized.includes("/uc/")) { + const required = [ + "title", + "code", + "course_code", + "plan_version", + "ects", + "semester", + "language", + ]; + required.forEach((field) => { + if (!data[field]) { + errors.push(`${normalized}: campo obrigatório '${field}' ausente.`); + } + }); + const outcomes = assertArray(data.learning_outcomes); + if (outcomes.length === 0) { + errors.push(`${normalized}: adicione pelo menos um item em 'learning_outcomes'.`); + } + const playlistErrors = validatePlaylists(assertArray(data.youtube_playlists), normalized); + errors.push(...playlistErrors); + } else { + const required = [ + "title", + "code", + "plan_version", + "degree", + "ects_total", + "duration_semesters", + "institution", + "school", + "language", + "summary", + ]; + required.forEach((field) => { + if (!data[field]) { + errors.push(`${normalized}: campo obrigatório '${field}' ausente.`); + } + }); + } + } else { + const required = ["title", "slug", "summary"]; + required.forEach((field) => { + if (!data[field]) { + errors.push(`${normalized}: campo obrigatório '${field}' ausente.`); + } + }); + const tags = assertArray(data.tags); + if (tags.length === 0) { + errors.push(`${normalized}: informe pelo menos uma tag.`); + } + const playlistErrors = validatePlaylists(assertArray(data.youtube_playlists), normalized); + errors.push(...playlistErrors); + } + } + + if (errors.length > 0) { + console.error("Falha na validação do frontmatter:\n" + errors.map((err) => `- ${err}`).join("\n")); + process.exitCode = 1; + } else { + console.log("Frontmatter válido para todos os arquivos de conteúdo."); + } +} + +main().catch((error) => { + console.error("Erro ao validar frontmatter:", error); + process.exitCode = 1; +}); diff --git a/static/js/loaders.js b/static/js/loaders.js new file mode 100644 index 00000000..d41e38bd --- /dev/null +++ b/static/js/loaders.js @@ -0,0 +1,391 @@ +import { ensureClient } from "./supabaseClient.js"; + +const isBrowser = typeof window !== "undefined"; + +function slugify(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function renderMarkdown(target, markdown) { + if (!target) return; + target.dataset.state = "ready"; + if (!markdown) { + target.innerHTML = '

Conteúdo indisponível.

'; + return; + } + if (isBrowser && window.marked) { + target.innerHTML = window.marked.parse(markdown); + } else { + target.textContent = markdown; + } +} + +function removePlaceholders(container) { + if (!container) return; + container.querySelectorAll("[data-placeholder]").forEach((node) => node.remove()); +} + +function showError(target, message) { + if (!target) return; + removePlaceholders(target); + target.innerHTML = ``; +} + +function createPlaylistLink(playlistId, priority) { + const wrapper = document.createElement("a"); + wrapper.className = "list-group-item list-group-item-action d-flex justify-content-between align-items-center"; + wrapper.href = `https://www.youtube.com/playlist?list=${playlistId}`; + wrapper.target = "_blank"; + wrapper.rel = "noopener"; + wrapper.textContent = playlistId; + if (priority != null) { + const badge = document.createElement("span"); + badge.className = "badge rounded-pill text-bg-primary"; + badge.textContent = `Prioridade ${priority}`; + wrapper.appendChild(badge); + } + return wrapper; +} + +function ensureContainer(selector) { + const node = document.querySelector(selector); + if (!node) { + console.warn(`FACODI: container ${selector} não encontrado.`); + } + return node; +} + +export async function loadCoursePage(courseCode, planVersion) { + if (!courseCode) return; + const detailsEl = ensureContainer("[data-course-details]"); + const contentEl = document.getElementById("course-content"); + const ucList = document.getElementById("course-uc-list"); + if (!detailsEl) return; + + let client; + try { + client = ensureClient(); + } catch (error) { + showError(contentEl, "Configure as variáveis do Supabase para visualizar os detalhes do curso."); + showError(ucList, "Configure as variáveis do Supabase para listar as UCs."); + return; + } + + try { + const { data: course, error } = await client + .from("catalog.course") + .select("code, name, degree, ects_total, duration_semesters, plan_version, institution, school, language, summary") + .eq("code", courseCode) + .eq("plan_version", planVersion) + .maybeSingle(); + if (error) throw error; + if (course) { + ["degree", "ects_total", "duration_semesters", "language", "institution", "school"].forEach((field) => { + const target = detailsEl.querySelector(`[data-field="${field}"]`); + if (target) { + target.textContent = course[field] ?? "—"; + } + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar curso", err); + } + + if (contentEl) { + try { + const { data, error } = await client + .from("catalog.course_content") + .select("content_md") + .eq("course_code", courseCode) + .eq("plan_version", planVersion) + .maybeSingle(); + if (error) throw error; + renderMarkdown(contentEl, data?.content_md ?? ""); + } catch (err) { + console.error("FACODI: erro ao carregar conteúdo do curso", err); + showError(contentEl, "Não foi possível carregar a ementa do curso."); + } + } + + if (ucList) { + try { + const { data: ucs, error } = await client + .from("catalog.uc") + .select("code, name, description, ects, semester, language") + .eq("course_code", courseCode) + .eq("plan_version", planVersion) + .order("semester", { ascending: true }); + if (error) throw error; + removePlaceholders(ucList); + if (!ucs || ucs.length === 0) { + ucList.innerHTML = '
Nenhuma UC cadastrada para este curso.
'; + return; + } + const coursePath = document.body?.dataset?.coursePath ?? window.location.pathname; + const normalizedPath = coursePath.endsWith("/") ? coursePath : `${coursePath}/`; + ucs.forEach((uc) => { + const col = document.createElement("div"); + col.className = "col-md-6"; + const card = document.createElement("article"); + card.className = "card h-100 shadow-sm border-0"; + const body = document.createElement("div"); + body.className = "card-body"; + const title = document.createElement("h3"); + title.className = "h5 mb-2"; + const link = document.createElement("a"); + link.className = "stretched-link text-decoration-none"; + const ucSlug = slugify(uc.code); + link.href = `${normalizedPath}uc/${ucSlug}/`; + link.textContent = uc.name ?? uc.code; + title.appendChild(link); + const description = document.createElement("p"); + description.className = "text-muted mb-3"; + description.textContent = uc.description ?? "Descrição não informada."; + const meta = document.createElement("p"); + meta.className = "small text-muted mb-0"; + meta.textContent = `Semestre ${uc.semester ?? "?"} · ${uc.ects ?? "?"} ECTS`; + body.appendChild(title); + body.appendChild(description); + body.appendChild(meta); + card.appendChild(body); + col.appendChild(card); + ucList.appendChild(col); + }); + } catch (err) { + console.error("FACODI: erro ao carregar UCs", err); + showError(ucList, "Não foi possível listar as UCs no momento."); + } + } +} + +export async function loadUCPage(ucCode) { + if (!ucCode) return; + const detailsEl = ensureContainer("[data-uc-details]"); + const contentEl = document.getElementById("uc-content"); + const outcomesEl = document.getElementById("uc-outcomes"); + const playlistsEl = document.getElementById("uc-playlist-list"); + const topicsEl = document.getElementById("uc-topic-list"); + if (!detailsEl) return; + + let client; + try { + client = ensureClient(); + } catch (error) { + showError(contentEl, "Configure as variáveis do Supabase para visualizar esta UC."); + return; + } + + try { + const { data: uc, error } = await client + .from("catalog.uc") + .select("code, description, ects, semester, language, prerequisites, course_code, plan_version") + .eq("code", ucCode) + .maybeSingle(); + if (error) throw error; + if (uc) { + const prereqTarget = detailsEl.querySelector('[data-field="prerequisites"]'); + if (prereqTarget) { + if (Array.isArray(uc.prerequisites) && uc.prerequisites.length > 0) { + prereqTarget.textContent = uc.prerequisites.join(", "); + } else { + prereqTarget.textContent = "Nenhum"; + } + } + ["ects", "semester", "language"].forEach((field) => { + const target = detailsEl.querySelector(`[data-field="${field}"]`); + if (target && uc[field] != null) { + target.textContent = uc[field]; + } + }); + } + } catch (err) { + console.error("FACODI: erro ao buscar detalhes da UC", err); + } + + if (contentEl) { + try { + const { data, error } = await client + .from("catalog.uc_content") + .select("content_md") + .eq("uc_code", ucCode) + .maybeSingle(); + if (error) throw error; + renderMarkdown(contentEl, data?.content_md ?? ""); + } catch (err) { + console.error("FACODI: erro ao carregar conteúdo da UC", err); + showError(contentEl, "Não foi possível carregar o conteúdo desta UC."); + } + } + + if (outcomesEl) { + try { + const { data, error } = await client + .from("catalog.uc_learning_outcome") + .select("outcome, order") + .eq("uc_code", ucCode) + .order("order", { ascending: true }); + if (error) throw error; + removePlaceholders(outcomesEl); + if (!data || data.length === 0) { + outcomesEl.innerHTML = '
  • Nenhum resultado de aprendizagem cadastrado.
  • '; + } else { + data.forEach((item) => { + const li = document.createElement("li"); + li.textContent = item.outcome; + outcomesEl.appendChild(li); + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar resultados de aprendizagem", err); + showError(outcomesEl, "Não foi possível carregar os resultados de aprendizagem."); + } + } + + if (playlistsEl) { + try { + const { data, error } = await client + .from("mapping.uc_playlist") + .select("playlist_id, priority") + .eq("uc_code", ucCode) + .order("priority", { ascending: true }); + if (error) throw error; + removePlaceholders(playlistsEl); + if (!data || data.length === 0) { + playlistsEl.innerHTML = '
    Nenhuma playlist associada.
    '; + } else { + data.forEach((playlist) => { + playlistsEl.appendChild(createPlaylistLink(playlist.playlist_id, playlist.priority)); + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar playlists da UC", err); + showError(playlistsEl, "Não foi possível carregar as playlists associadas."); + } + } + + if (topicsEl) { + try { + const { data, error } = await client + .from("mapping.uc_topic") + .select("topic:topic_slug (slug, name, summary)") + .eq("uc_code", ucCode) + .order("topic.slug", { ascending: true }); + if (error) throw error; + removePlaceholders(topicsEl); + if (!data || data.length === 0) { + topicsEl.innerHTML = '
    Nenhum tópico foi mapeado para esta UC.
    '; + } else { + const ucPath = document.body?.dataset?.ucPath ?? window.location.pathname; + const normalizedPath = ucPath.endsWith("/") ? ucPath : `${ucPath}/`; + data.forEach((entry) => { + if (!entry.topic) return; + const col = document.createElement("div"); + col.className = "col-md-6"; + const card = document.createElement("article"); + card.className = "card h-100 border-0 shadow-sm"; + const body = document.createElement("div"); + body.className = "card-body"; + const h3 = document.createElement("h3"); + h3.className = "h5"; + const link = document.createElement("a"); + link.className = "stretched-link text-decoration-none"; + link.href = `${normalizedPath}${entry.topic.slug}/`; + link.textContent = entry.topic.name ?? entry.topic.slug; + h3.appendChild(link); + const summary = document.createElement("p"); + summary.className = "text-muted mb-0"; + summary.textContent = entry.topic.summary ?? "Resumo não informado."; + body.appendChild(h3); + body.appendChild(summary); + card.appendChild(body); + col.appendChild(card); + topicsEl.appendChild(col); + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar tópicos da UC", err); + showError(topicsEl, "Não foi possível carregar os tópicos desta UC."); + } + } +} + +export async function loadTopicPage(topicSlug) { + if (!topicSlug) return; + const contentEl = document.getElementById("topic-content"); + const playlistEl = document.getElementById("topic-playlist-list"); + const tagListEl = document.getElementById("topic-tag-list"); + + let client; + try { + client = ensureClient(); + } catch (error) { + showError(contentEl, "Configure as variáveis do Supabase para visualizar o tópico."); + return; + } + + if (contentEl) { + try { + const { data, error } = await client + .from("subjects.topic_content") + .select("content_md") + .eq("topic_slug", topicSlug) + .maybeSingle(); + if (error) throw error; + renderMarkdown(contentEl, data?.content_md ?? ""); + } catch (err) { + console.error("FACODI: erro ao carregar conteúdo do tópico", err); + showError(contentEl, "Não foi possível carregar o conteúdo deste tópico."); + } + } + + if (playlistEl) { + try { + const { data, error } = await client + .from("mapping.topic_playlist") + .select("playlist_id, priority") + .eq("topic_slug", topicSlug) + .order("priority", { ascending: true }); + if (error) throw error; + removePlaceholders(playlistEl); + if (!data || data.length === 0) { + playlistEl.innerHTML = '
    Nenhuma playlist cadastrada para este tópico.
    '; + } else { + data.forEach((playlist) => { + playlistEl.appendChild(createPlaylistLink(playlist.playlist_id, playlist.priority)); + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar playlists do tópico", err); + showError(playlistEl, "Não foi possível carregar as playlists deste tópico."); + } + } + + if (tagListEl) { + try { + const { data, error } = await client + .from("subjects.topic_tag") + .select("tag") + .eq("topic_slug", topicSlug) + .order("tag", { ascending: true }); + if (error) throw error; + removePlaceholders(tagListEl); + if (!data || data.length === 0) { + tagListEl.innerHTML = '
  • Sem tags cadastradas.
  • '; + } else { + data.forEach((row) => { + const item = document.createElement("li"); + item.className = "list-inline-item badge rounded-pill text-bg-secondary me-2"; + item.textContent = row.tag; + tagListEl.appendChild(item); + }); + } + } catch (err) { + console.error("FACODI: erro ao carregar tags do tópico", err); + showError(tagListEl, "Não foi possível carregar as tags deste tópico."); + } + } +} diff --git a/static/js/supabaseClient.js b/static/js/supabaseClient.js new file mode 100644 index 00000000..19467ead --- /dev/null +++ b/static/js/supabaseClient.js @@ -0,0 +1,17 @@ +import { createClient } from "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/+esm"; + +const url = window?.SUPABASE_URL; +const anonKey = window?.SUPABASE_ANON_KEY; + +if (!url || !anonKey) { + console.warn("FACODI: variáveis SUPABASE_URL e/ou SUPABASE_ANON_KEY não definidas. Conteúdo dinâmico ficará indisponível."); +} + +export const supabase = url && anonKey ? createClient(url, anonKey, { auth: { persistSession: false } }) : null; + +export function ensureClient() { + if (!supabase) { + throw new Error("Supabase client not initialized"); + } + return supabase; +} diff --git a/supabase/migrations/20250110000000_create_catalog.sql b/supabase/migrations/20250110000000_create_catalog.sql new file mode 100644 index 00000000..79739af1 --- /dev/null +++ b/supabase/migrations/20250110000000_create_catalog.sql @@ -0,0 +1,151 @@ +-- Create schemas +create schema if not exists catalog; +create schema if not exists subjects; +create schema if not exists mapping; + +-- Courses +create table if not exists catalog.course ( + code text not null, + plan_version text not null, + name text not null, + degree text, + ects_total integer, + duration_semesters integer, + institution text, + school text, + language text, + summary text, + inserted_at timestamptz default now(), + updated_at timestamptz default now(), + constraint course_pk primary key (code, plan_version) +); + +create table if not exists catalog.course_content ( + course_code text not null, + plan_version text not null, + content_md text not null, + constraint course_content_pk primary key (course_code, plan_version), + constraint course_content_course_fk foreign key (course_code, plan_version) + references catalog.course (code, plan_version) on delete cascade +); + +-- Curricular units +create table if not exists catalog.uc ( + code text primary key, + course_code text not null, + plan_version text not null, + name text not null, + description text, + ects integer, + semester integer, + language text, + prerequisites text[] default '{}', + inserted_at timestamptz default now(), + updated_at timestamptz default now(), + constraint uc_course_fk foreign key (course_code, plan_version) + references catalog.course (code, plan_version) on delete cascade +); + +create table if not exists catalog.uc_content ( + uc_code text primary key, + content_md text not null, + constraint uc_content_uc_fk foreign key (uc_code) + references catalog.uc (code) on delete cascade +); + +create table if not exists catalog.uc_learning_outcome ( + uc_code text not null, + outcome text not null, + "order" integer not null, + constraint uc_learning_outcome_pk primary key (uc_code, "order"), + constraint uc_learning_outcome_uc_fk foreign key (uc_code) + references catalog.uc (code) on delete cascade +); + +-- Topics +create table if not exists subjects.topic ( + slug text primary key, + name text not null, + summary text +); + +create table if not exists subjects.topic_content ( + topic_slug text primary key, + content_md text not null, + constraint topic_content_topic_fk foreign key (topic_slug) + references subjects.topic (slug) on delete cascade +); + +create table if not exists subjects.topic_tag ( + topic_slug text not null, + tag text not null, + constraint topic_tag_pk primary key (topic_slug, tag), + constraint topic_tag_topic_fk foreign key (topic_slug) + references subjects.topic (slug) on delete cascade +); + +-- Mappings +create table if not exists mapping.uc_topic ( + uc_code text not null, + topic_slug text not null, + constraint uc_topic_pk primary key (uc_code, topic_slug), + constraint uc_topic_uc_fk foreign key (uc_code) + references catalog.uc (code) on delete cascade, + constraint uc_topic_topic_fk foreign key (topic_slug) + references subjects.topic (slug) on delete cascade +); + +create table if not exists mapping.uc_playlist ( + uc_code text not null, + playlist_id text not null, + priority integer, + constraint uc_playlist_pk primary key (uc_code, playlist_id), + constraint uc_playlist_uc_fk foreign key (uc_code) + references catalog.uc (code) on delete cascade +); + +create table if not exists mapping.topic_playlist ( + topic_slug text not null, + playlist_id text not null, + priority integer, + constraint topic_playlist_pk primary key (topic_slug, playlist_id), + constraint topic_playlist_topic_fk foreign key (topic_slug) + references subjects.topic (slug) on delete cascade +); + +-- RLS policies +alter table catalog.course enable row level security; +alter table catalog.course_content enable row level security; +alter table catalog.uc enable row level security; +alter table catalog.uc_content enable row level security; +alter table catalog.uc_learning_outcome enable row level security; +alter table subjects.topic enable row level security; +alter table subjects.topic_content enable row level security; +alter table subjects.topic_tag enable row level security; +alter table mapping.uc_topic enable row level security; +alter table mapping.uc_playlist enable row level security; +alter table mapping.topic_playlist enable row level security; + +create policy "Allow anon read" on catalog.course for select using (true); +create policy "Allow anon read" on catalog.course_content for select using (true); +create policy "Allow anon read" on catalog.uc for select using (true); +create policy "Allow anon read" on catalog.uc_content for select using (true); +create policy "Allow anon read" on catalog.uc_learning_outcome for select using (true); +create policy "Allow anon read" on subjects.topic for select using (true); +create policy "Allow anon read" on subjects.topic_content for select using (true); +create policy "Allow anon read" on subjects.topic_tag for select using (true); +create policy "Allow anon read" on mapping.uc_topic for select using (true); +create policy "Allow anon read" on mapping.uc_playlist for select using (true); +create policy "Allow anon read" on mapping.topic_playlist for select using (true); + +create policy "Service role full access" on catalog.course for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on catalog.course_content for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on catalog.uc for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on catalog.uc_content for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on catalog.uc_learning_outcome for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on subjects.topic for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on subjects.topic_content for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on subjects.topic_tag for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on mapping.uc_topic for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on mapping.uc_playlist for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); +create policy "Service role full access" on mapping.topic_playlist for all using (auth.role() = 'service_role') with check (auth.role() = 'service_role'); diff --git a/supabase/seeds/20250110000100_seed_catalog.sql b/supabase/seeds/20250110000100_seed_catalog.sql new file mode 100644 index 00000000..e76c40a0 --- /dev/null +++ b/supabase/seeds/20250110000100_seed_catalog.sql @@ -0,0 +1,93 @@ +insert into catalog.course (code, plan_version, name, degree, ects_total, duration_semesters, institution, school, language, summary) +values ( + 'LESTI', + '2024/2025', + 'Licenciatura em Engenharia de Software e Tecnologia de Informação', + 'bachelor', + 180, + 6, + 'Universidade do Algarve', + 'Escola Superior de Tecnologia', + 'pt', + 'Curso focado em formar profissionais capazes de projetar, implementar e manter sistemas de software com foco em impacto social.' +) +on conflict (code, plan_version) do update set + name = excluded.name, + degree = excluded.degree, + ects_total = excluded.ects_total, + duration_semesters = excluded.duration_semesters, + institution = excluded.institution, + school = excluded.school, + language = excluded.language, + summary = excluded.summary; + +insert into catalog.course_content (course_code, plan_version, content_md) +values ( + 'LESTI', + '2024/2025', + $$O plano curricular foi adaptado para oferecer uma experiência aberta e colaborativa. Cada unidade curricular possui playlists, tópicos e resultados de aprendizagem acompanhados por voluntários da comunidade.$$) +on conflict (course_code, plan_version) do update set + content_md = excluded.content_md; + +insert into catalog.uc (code, course_code, plan_version, name, description, ects, semester, language, prerequisites) +values ( + 'LESTI-ALG1', + 'LESTI', + '2024/2025', + 'Algoritmos I', + 'Introdução a análise e implementação de algoritmos fundamentais.', + 6, + 1, + 'pt', + '{}' +) +on conflict (code) do update set + name = excluded.name, + description = excluded.description, + ects = excluded.ects, + semester = excluded.semester, + language = excluded.language, + prerequisites = excluded.prerequisites; + +insert into catalog.uc_content (uc_code, content_md) +values ( + 'LESTI-ALG1', + $$Esta unidade curricular introduz fundamentos de algoritmos, estruturando a aprendizagem com foco em resolução de problemas e uso de linguagens de programação de alto nível.$$) +on conflict (uc_code) do update set + content_md = excluded.content_md; + +delete from catalog.uc_learning_outcome where uc_code = 'LESTI-ALG1'; +insert into catalog.uc_learning_outcome (uc_code, outcome, "order") +values + ('LESTI-ALG1', 'Aplicar princípios de análise de algoritmos para resolver problemas estruturados.', 1), + ('LESTI-ALG1', 'Comparar estruturas de dados básicas identificando vantagens e limitações.', 2), + ('LESTI-ALG1', 'Implementar algoritmos iterativos e recursivos utilizando boas práticas de código.', 3); + +delete from mapping.uc_playlist where uc_code = 'LESTI-ALG1'; +insert into mapping.uc_playlist (uc_code, playlist_id, priority) +values ('LESTI-ALG1', 'PL123456789ALG1', 1); + +insert into subjects.topic (slug, name, summary) +values ('estruturas-de-dados', 'Estruturas de Dados', 'Introdução a listas, pilhas, filas e árvores para organizar informação.') +on conflict (slug) do update set + name = excluded.name, + summary = excluded.summary; + +insert into subjects.topic_content (topic_slug, content_md) +values ('estruturas-de-dados', $$Aprofundamos listas ligadas, pilhas, filas e árvores binárias, sempre relacionando a escolha da estrutura ao tipo de problema a ser resolvido.$$) +on conflict (topic_slug) do update set + content_md = excluded.content_md; + +delete from subjects.topic_tag where topic_slug = 'estruturas-de-dados'; +insert into subjects.topic_tag (topic_slug, tag) +values + ('estruturas-de-dados', 'algoritmos'), + ('estruturas-de-dados', 'estrutura-de-dados'); + +delete from mapping.uc_topic where uc_code = 'LESTI-ALG1'; +insert into mapping.uc_topic (uc_code, topic_slug) +values ('LESTI-ALG1', 'estruturas-de-dados'); + +delete from mapping.topic_playlist where topic_slug = 'estruturas-de-dados'; +insert into mapping.topic_playlist (topic_slug, playlist_id, priority) +values ('estruturas-de-dados', 'PL987654321TOPICO', 1);