feat(maintenance): new maintenance page (#9445)

This commit is contained in:
Ulf Gebhardt 2026-03-29 07:27:02 +02:00 committed by GitHub
parent 602fa0c26e
commit 01a951e77b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 22748 additions and 343 deletions

View File

@ -127,6 +127,43 @@ updates:
timezone: "Europe/Berlin"
time: "03:00"
# maintenance
- package-ecosystem: docker
open-pull-requests-limit: 99
directory: "/maintenance"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/maintenance"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
groups:
nuxt:
applies-to: version-updates
patterns:
- "nuxt*"
- "@nuxt*"
- "@nuxtjs*"
vitest:
applies-to: version-updates
patterns:
- "vitest*"
- "@vitest*"
tailwind:
applies-to: version-updates
patterns:
- "tailwindcss*"
- "@tailwindcss*"
# ui library
- package-ecosystem: npm
open-pull-requests-limit: 99

View File

@ -4,6 +4,11 @@ ui: &ui
- '.github/workflows/ui-*.yml'
- 'packages/ui/**/*'
maintenance: &maintenance
- '.github/workflows/maintenance-*.yml'
- 'maintenance/**/*'
- *ui
backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*'

View File

@ -37,15 +37,15 @@ jobs:
target: production
- name: maintenance-base
context: .
file: webapp/Dockerfile.maintenance
file: maintenance/Dockerfile
target: base
- name: maintenance-build
context: .
file: webapp/Dockerfile.maintenance
file: maintenance/Dockerfile
target: build
- name: maintenance
context: .
file: webapp/Dockerfile.maintenance
file: maintenance/Dockerfile
target: production
runs-on: ubuntu-latest
env:

77
.github/workflows/maintenance-build.yml vendored Normal file
View File

@ -0,0 +1,77 @@
name: Maintenance Build
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: maintenance
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
maintenance: ${{ steps.changes.outputs.maintenance }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build
if: needs.files-changed.outputs.maintenance == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'maintenance/.tool-versions'
cache: 'npm'
cache-dependency-path: maintenance/package-lock.json
- name: Build UI library
working-directory: packages/ui
run: npm ci && npm run build
- name: Install dependencies
run: npm ci
- name: Generate static site
run: npx nuxt generate
- name: Verify build output
run: |
if [ ! -d ".output/public" ]; then
echo "::error::.output/public directory not found"
exit 1
fi
if [ ! -f ".output/public/index.html" ]; then
echo "::error::index.html not found in build output"
exit 1
fi
echo "Build output verified!"
ls -la .output/public/
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: maintenance-site
path: maintenance/.output/public/
retention-days: 7

View File

@ -0,0 +1,59 @@
name: Maintenance Docker
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
maintenance: ${{ steps.changes.outputs.maintenance }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build Docker Image
if: needs.files-changed.outputs.maintenance == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build development image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./maintenance/Dockerfile
target: development
push: false
tags: ocelot-social/maintenance:development
cache-from: type=gha,scope=maintenance-development
cache-to: type=gha,mode=max,scope=maintenance-development
- name: Build production image
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./maintenance/Dockerfile
target: production
push: false
tags: ocelot-social/maintenance:latest
cache-from: type=gha,scope=maintenance-production
cache-to: type=gha,mode=max,scope=maintenance-production

58
.github/workflows/maintenance-lint.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Maintenance Lint
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: maintenance
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
maintenance: ${{ steps.changes.outputs.maintenance }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
lint:
name: ESLint
if: needs.files-changed.outputs.maintenance == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'maintenance/.tool-versions'
cache: 'npm'
cache-dependency-path: maintenance/package-lock.json
- name: Build UI library
working-directory: packages/ui
run: npm ci && npm run build
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type check
run: npx nuxi typecheck

63
.github/workflows/maintenance-test.yml vendored Normal file
View File

@ -0,0 +1,63 @@
name: Maintenance Test
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: maintenance
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
maintenance: ${{ steps.changes.outputs.maintenance }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
test:
name: Unit Tests
if: needs.files-changed.outputs.maintenance == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'maintenance/.tool-versions'
cache: 'npm'
cache-dependency-path: maintenance/package-lock.json
- name: Build UI library
working-directory: packages/ui
run: npm ci && npm run build
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npx vitest run --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: always()
with:
name: maintenance-coverage-report
path: maintenance/coverage/
retention-days: 7

View File

@ -21,4 +21,4 @@ spec:
- name: HOST
value: 0.0.0.0
ports:
- containerPort: 80
- containerPort: 8080

View File

@ -6,6 +6,6 @@ spec:
ports:
- name: {{ .Release.Name }}-http
port: 80
targetPort: 80
targetPort: 8080
selector:
app: {{ .Release.Name }}-maintenance

View File

@ -15,6 +15,6 @@ services:
maintenance:
image: ghcr.io/ocelot-social-community/ocelot-social/maintenance-base:${OCELOT_VERSION:-latest}
build:
target: base
context: webapp
dockerfile: ./Dockerfile.maintenance
target: build
context: .
dockerfile: ./maintenance/Dockerfile

View File

@ -17,4 +17,4 @@ services:
build:
target: build
context: .
dockerfile: ./Dockerfile.maintenance
dockerfile: ./maintenance/Dockerfile

View File

@ -94,6 +94,18 @@ services:
HTTP_LOADER_BASE_URL: http://minio:9000
IMAGOR_CACHE_HEADER_TTL: 168h # 7 days
maintenance:
image: ghcr.io/ocelot-social-community/ocelot-social/maintenance:local-development
build:
target: development
ports:
- "3001:3000"
volumes:
- ./maintenance/app:/app/app
- ./maintenance/nuxt.config.ts:/app/nuxt.config.ts
- ./packages/ui:/packages/ui
- /packages/ui/node_modules
ui:
image: ghcr.io/ocelot-social-community/ocelot-social/ui:local-development
build:

View File

@ -52,9 +52,8 @@ services:
image: ghcr.io/ocelot-social-community/ocelot-social/maintenance:${OCELOT_VERSION:-latest}
build:
context: .
dockerfile: ./webapp/Dockerfile.maintenance
ports:
- 3001:80
dockerfile: ./maintenance/Dockerfile
target: production
neo4j:
image: ghcr.io/ocelot-social-community/ocelot-social/neo4j:community

24
maintenance/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

1
maintenance/.nuxtrc Normal file
View File

@ -0,0 +1 @@
setups.@nuxt/test-utils="4.0.0"

View File

@ -0,0 +1 @@
nodejs 25.5.0

64
maintenance/Dockerfile Normal file
View File

@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1
# ==============================================================================
# Stage 1: Build UI library
# ==============================================================================
FROM node:25.8.1-alpine AS ui-library
RUN apk --no-cache add git python3 make g++
WORKDIR /packages/ui
COPY packages/ui/ .
RUN npm ci
RUN npm run build
# ==============================================================================
# Stage 2: Install dependencies and copy sources
# ==============================================================================
FROM node:25.8.1-alpine AS base
RUN apk --no-cache add git python3 make g++
WORKDIR /app
COPY --from=ui-library /packages/ui/ /packages/ui/
COPY maintenance/package.json maintenance/package-lock.json ./
RUN npm ci
COPY maintenance/ .
# ==============================================================================
# Stage 3: Development (hot-reload with mounted sources)
# ==============================================================================
FROM base AS development
ENV NODE_ENV=development
EXPOSE 3000
CMD ["npx", "nuxt", "dev"]
# ==============================================================================
# Stage 4: Generate static site
# ==============================================================================
FROM base AS build
# Branding: reuse existing branding repo structure
# Static assets (logo, favicon) — Nuxt 4 uses public/ instead of static/
ONBUILD COPY brandin[g]/static/ /app/public/
# Constants — copy JS to TS (Nuxt 4 TypeScript)
ONBUILD COPY brandin[g]/constants/metadata.j[s] /app/app/constants/metadata.ts
ONBUILD COPY brandin[g]/constants/emails.j[s] /app/app/constants/emails.ts
# Locales — merge branding translations into defaults (same mechanism as webapp)
ONBUILD COPY brandin[g]/locales/*.jso[n] /app/locales/tmp/
ONBUILD RUN if [ -d locales/tmp ]; then apk add --no-cache jq && tools/merge-locales.sh; fi
ONBUILD RUN npx nuxt generate
RUN npx nuxt generate
# ==============================================================================
# Stage 5: Production (static site served by nginx)
# ==============================================================================
FROM nginx:1.29.6-alpine AS production
LABEL org.label-schema.name="ocelot.social:maintenance"
LABEL org.label-schema.description="Maintenance page of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/maintenance"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
COPY --from=build /app/.output/public/ /usr/share/nginx/html/
COPY maintenance/nginx/custom.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
USER nginx

74
maintenance/README.md Normal file
View File

@ -0,0 +1,74 @@
# Maintenance Page
Static maintenance page for [ocelot.social](https://ocelot.social) instances. Shown to users during planned downtime, returning HTTP 503 for all routes so search engines know the outage is temporary.
Built with **Nuxt 4** (static generation), **Tailwind CSS v4**, and the **@ocelot-social/ui** component library. Supports 11 languages via `@nuxtjs/i18n`.
## Development
```bash
npm install
npm run dev # http://localhost:3000
```
## Testing & Linting
```bash
npm test # vitest
npm run lint # eslint
```
## Production Build
The app is generated as a fully static site (`nuxt generate`) and served by nginx.
```bash
npm run generate # outputs to .output/public/
npm run preview # local preview of the static build
```
## Docker
Multi-stage Dockerfile:
| Stage | Purpose |
|---------------|----------------------------------------------------|
| `ui-library` | Builds `@ocelot-social/ui` from `packages/ui/` |
| `build` | Installs deps, applies branding, runs `nuxt generate` |
| `development` | Hot-reload dev server (mount sources) |
| `production` | nginx alpine serving the static files |
Build context must be the repo root so Docker can access `packages/ui/`:
```bash
docker build -f maintenance/Dockerfile --target production -t maintenance .
docker run -p 8080:8080 maintenance
```
## Branding
The Dockerfile uses `ONBUILD` instructions to overlay instance-specific branding:
- `branding/static/` — logo, favicon, and other public assets
- `branding/constants/metadata.js` — site name, description, etc.
- `branding/constants/emails.js` — contact email addresses
- `branding/locales/*.json` — translation overrides (merged via `tools/merge-locales.sh`)
## Nginx
The nginx config (`nginx/custom.conf`) returns **503** for all non-asset requests, serving `index.html` as the error page. This signals to search engines that the downtime is temporary.
## Project Structure
```text
maintenance/
├── app/ # Nuxt application source
│ ├── assets/css/ # Tailwind & branding CSS
│ ├── components/ # Vue components
│ ├── constants/ # Branding constants (metadata, emails)
│ └── plugins/ # Nuxt plugins
├── locales/ # i18n translation files (11 languages)
├── nginx/ # nginx config for production
├── public/ # Static assets (favicon, logo)
└── tools/ # Build helper scripts
```

View File

@ -0,0 +1,40 @@
import { mountSuspended } from "@nuxt/test-utils/runtime";
import { describe, expect, it } from "vitest";
import App from "./app.vue";
describe("app", () => {
it("renders maintenance heading", async () => {
const wrapper = await mountSuspended(App);
expect(wrapper.find("h1").text()).toContain("is under maintenance");
});
it("renders explanation text", async () => {
const wrapper = await mountSuspended(App);
expect(wrapper.text()).toContain("scheduled maintenance");
});
it("renders support email link", async () => {
const wrapper = await mountSuspended(App);
const link = wrapper.find('a[href="mailto:devops@ocelot.social"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe("devops@ocelot.social");
});
it("renders logo", async () => {
const wrapper = await mountSuspended(App);
const img = wrapper.find("img.logo");
expect(img.exists()).toBe(true);
expect(img.attributes("src")).toBe("/img/custom/logo-squared.svg");
});
it("renders OsCard component", async () => {
const wrapper = await mountSuspended(App);
expect(wrapper.find(".os-card").exists()).toBe(true);
});
it("renders LocaleSwitch component", async () => {
const wrapper = await mountSuspended(App);
expect(wrapper.find(".locale-switch").exists()).toBe(true);
});
});

142
maintenance/app/app.vue Normal file
View File

@ -0,0 +1,142 @@
<template>
<div class="container">
<OsCard>
<div class="card-inner">
<div class="locale-switch">
<LocaleSwitch />
</div>
<div class="layout">
<div class="layout__image">
<img
class="logo"
:alt="t('maintenance.title', metadata)"
:src="logoUrl"
/>
</div>
<div class="layout__content">
<h1 class="heading">{{ t("maintenance.title", metadata) }}</h1>
<p class="text">{{ t("maintenance.explanation") }}</p>
<p class="text">
{{ t("maintenance.questions") }}
<a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>
</p>
</div>
</div>
</div>
</OsCard>
</div>
</template>
<script setup lang="ts">
import { OsCard } from "@ocelot-social/ui";
import LocaleSwitch from "~/components/LocaleSwitch.vue";
import emails from "~/constants/emails";
import metadata from "~/constants/metadata";
const { t } = useI18n();
const supportEmail = emails.SUPPORT_EMAIL;
const logoUrl = "/img/custom/logo-squared.svg";
const pageTitle = computed(() => t("maintenance.title", metadata));
useHead({
title: pageTitle,
meta: [
{ name: "description", content: metadata.APPLICATION_DESCRIPTION },
{ name: "theme-color", content: metadata.THEME_COLOR },
{ property: "og:title", content: pageTitle },
{ property: "og:description", content: metadata.APPLICATION_DESCRIPTION },
{ property: "og:image", content: metadata.OG_IMAGE },
{ property: "og:image:alt", content: metadata.OG_IMAGE_ALT },
{ property: "og:image:width", content: metadata.OG_IMAGE_WIDTH },
{ property: "og:image:height", content: metadata.OG_IMAGE_HEIGHT },
{ property: "og:image:type", content: metadata.OG_IMAGE_TYPE },
{ property: "og:type", content: "website" },
],
});
</script>
<style scoped>
.container {
max-width: 700px;
margin: 80px auto;
padding: 0 16px;
}
.card-inner {
position: relative;
min-height: 280px;
display: flex;
align-items: center;
}
.locale-switch {
position: absolute;
top: 0;
left: 0;
z-index: 10;
}
.layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 0 16px;
width: 100%;
}
.layout__image {
display: flex;
justify-content: center;
}
@media (min-width: 640px) {
.layout {
flex-direction: row;
align-items: center;
}
.layout__image {
flex: 0 0 35%;
}
.layout__content {
flex: 1 1 0;
}
}
.logo {
width: 90%;
max-width: 200px;
height: auto;
}
.heading {
font-family: LatoWeb, sans-serif;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.1;
color: var(--color-text-base);
margin: 0 0 1em;
}
.text {
font-family: LatoWeb, sans-serif;
color: var(--color-text-base);
line-height: 1.3;
margin: 0 0 1em;
}
.text a {
color: var(--color-primary);
text-decoration: none;
}
.text a:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,123 @@
/*
* Default ocelot.social branding
*
* Resolved values from webapp SCSS tokens + styleguide design system.
* Source of truth: webapp/assets/_new/styles/
*
* Branding repositories can override this file.
*/
/* Fonts — same as webapp/assets/_new/styles/resets.scss */
@font-face {
font-family: LatoWeb;
src:
url("/fonts/Lato-Regular.1f440a46.woff2") format("woff2"),
url("/fonts/Lato-Regular.ffb25090.woff") format("woff");
font-style: normal;
font-weight: 400;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src:
url("/fonts/Lato-Italic.a6774e2c.woff2") format("woff2"),
url("/fonts/Lato-Italic.ff8877c4.woff") format("woff");
font-style: italic;
font-weight: 400;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src:
url("/fonts/Lato-Bold.1e239003.woff2") format("woff2"),
url("/fonts/Lato-Bold.35be9fc3.woff") format("woff");
font-style: normal;
font-weight: 600;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src:
url("/fonts/Lato-BoldItalic.4ef02877.woff2") format("woff2"),
url("/fonts/Lato-BoldItalic.5171ee7d.woff") format("woff");
font-style: italic;
font-weight: 600;
font-display: swap;
text-rendering: optimizelegibility;
}
/* Base styles — same as webapp */
html {
font-size: 15px;
}
body {
font-family: LatoWeb, sans-serif;
font-size: 15px;
line-height: 1.3;
margin: 0;
background-color: rgb(245, 244, 246); /* $color-neutral-90 */
color: rgb(75, 69, 84); /* $text-color-base */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: none;
font-variant-ligatures: none;
}
/* CSS Custom Properties for @ocelot-social/ui */
:root {
/* Default (grey) */
--color-default: rgb(245, 244, 246);
--color-default-hover: rgb(203, 199, 209);
--color-default-active: rgb(177, 171, 186);
--color-default-contrast: rgb(75, 69, 84);
--color-default-contrast-inverse: rgb(255, 255, 255);
/* Primary (green) */
--color-primary: rgb(23, 181, 63);
--color-primary-hover: rgb(96, 214, 98);
--color-primary-active: rgb(25, 122, 49);
--color-primary-contrast: rgb(241, 253, 244);
/* Secondary (blue) */
--color-secondary: rgb(0, 142, 230);
--color-secondary-hover: rgb(10, 161, 255);
--color-secondary-active: rgb(0, 91, 166);
--color-secondary-contrast: rgb(240, 249, 255);
/* Danger (red) */
--color-danger: rgb(219, 57, 36);
--color-danger-hover: rgb(242, 97, 65);
--color-danger-active: rgb(158, 43, 28);
--color-danger-contrast: rgb(253, 243, 242);
/* Warning (orange) */
--color-warning: rgb(230, 121, 25);
--color-warning-hover: rgb(233, 137, 53);
--color-warning-active: rgb(172, 81, 0);
--color-warning-contrast: rgb(241, 253, 244);
/* Success (green) */
--color-success: rgb(23, 181, 63);
--color-success-hover: rgb(26, 203, 71);
--color-success-active: rgb(6, 131, 35);
--color-success-contrast: rgb(241, 253, 244);
/* Info (blue) */
--color-info: rgb(0, 142, 230);
--color-info-hover: rgb(10, 161, 255);
--color-info-active: rgb(0, 91, 166);
--color-info-contrast: rgb(240, 249, 255);
/* Disabled */
--color-disabled: rgb(177, 171, 186);
--color-disabled-contrast: rgb(255, 255, 255);
/* Text */
--color-text-base: rgb(75, 69, 84);
--color-text-soft: rgb(112, 103, 126);
--color-background-soft: rgb(250, 249, 250);
}

View File

@ -0,0 +1,4 @@
@import "tailwindcss";
/* Scan UI library component classes */
@source "../../../packages/ui/dist/*.mjs";

View File

@ -0,0 +1,103 @@
import { readdirSync } from "node:fs";
import { resolve } from "node:path";
import { mountSuspended } from "@nuxt/test-utils/runtime";
import { afterEach, describe, expect, it } from "vitest";
import { nextTick } from "vue";
import LocaleSwitch from "./LocaleSwitch.vue";
const LOCALE_COUNT = readdirSync(resolve(__dirname, "../../locales")).filter(
(f) => f.endsWith(".json"),
).length;
async function openDropdown(
wrapper: Awaited<ReturnType<typeof mountSuspended>>,
) {
await wrapper.find("button").trigger("click");
await nextTick();
// floating-vue needs time to complete its show transition
await new Promise((r) => setTimeout(r, 50));
await nextTick();
}
describe("LocaleSwitch", () => {
// Clean up floating-vue teleported popper elements between tests
afterEach(() => {
document.querySelectorAll(".v-popper__popper").forEach((el) => el.remove());
});
it("renders a language icon button", async () => {
const wrapper = await mountSuspended(LocaleSwitch);
const button = wrapper.find("button");
expect(button.exists()).toBe(true);
expect(button.attributes("aria-label")).toBe("Choose language");
});
it("opens dropdown on button click", async () => {
const wrapper = await mountSuspended(LocaleSwitch, {
attachTo: document.body,
});
await openDropdown(wrapper);
const items = document.querySelectorAll(".os-menu-item-link");
expect(items.length).toBeGreaterThan(0);
wrapper.unmount();
});
it("shows all configured locales in dropdown", async () => {
const wrapper = await mountSuspended(LocaleSwitch, {
attachTo: document.body,
});
await openDropdown(wrapper);
const items = document.querySelectorAll(".os-menu-item-link");
expect(items.length).toBe(LOCALE_COUNT);
wrapper.unmount();
});
it("displays sorted locale names", async () => {
const wrapper = await mountSuspended(LocaleSwitch, {
attachTo: document.body,
});
await openDropdown(wrapper);
const items = document.querySelectorAll(".os-menu-item-link");
const names = Array.from(items).map((el) => el.textContent?.trim() ?? "");
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
wrapper.unmount();
});
it("marks current locale as active", async () => {
const wrapper = await mountSuspended(LocaleSwitch, {
attachTo: document.body,
});
await openDropdown(wrapper);
const active = document.querySelector(".os-menu-item--active");
expect(active?.textContent?.trim()).toBe("English");
wrapper.unmount();
});
it("switches locale on click", async () => {
const wrapper = await mountSuspended(LocaleSwitch, {
attachTo: document.body,
});
await openDropdown(wrapper);
const deutsch = Array.from(
document.querySelectorAll(".os-menu-item-link"),
).find((el) => el.textContent?.trim() === "Deutsch") as
| HTMLButtonElement
| undefined;
expect(
deutsch,
'Expected "Deutsch" locale item to exist in dropdown',
).toBeDefined();
deutsch!.click();
// floating-vue needs time to close and update after locale switch
await new Promise((r) => setTimeout(r, 100));
await nextTick();
// Re-open to check active state
await openDropdown(wrapper);
const active = document.querySelector(".os-menu-item--active");
expect(active?.textContent?.trim()).toBe("Deutsch");
wrapper.unmount();
});
});

View File

@ -0,0 +1,66 @@
<template>
<VDropdown :distance="8" placement="bottom-start">
<OsButton
variant="primary"
appearance="ghost"
circle
:aria-label="t('localeSwitch.tooltip')"
:title="t('localeSwitch.tooltip')"
>
<template #icon>
<OsIcon :icon="languageIcon" />
</template>
</OsButton>
<template #popper="{ hide }">
<OsMenu
dropdown
link-tag="button"
:routes="sortedLocales"
:name-parser="(r: Record<string, unknown>) => r.name as string"
:matcher="
(_url: string, r: Record<string, unknown>) => r.code === locale
"
>
<template #menuitem="{ route, parents }">
<OsMenuItem
:route="route"
:parents="parents"
@click="
(_e: Event, r: Record<string, unknown>) =>
switchLocale(r.code as LocaleCode, hide)
"
/>
</template>
</OsMenu>
</template>
</VDropdown>
</template>
<script setup lang="ts">
import { OsButton, OsIcon, OsMenu, OsMenuItem } from "@ocelot-social/ui";
import { ocelotIcons } from "@ocelot-social/ui/ocelot";
import type { GeneratedTypeConfig } from "@intlify/core-base";
type LocaleCode = GeneratedTypeConfig["locale"];
const { locale, locales, setLocale, t } = useI18n();
const languageIcon = ocelotIcons.language;
const sortedLocales = computed(() =>
locales.value
.filter((l) => typeof l !== "string" && l.name)
.map((l) => ({
code: (l as { code: LocaleCode }).code,
name: (l as { name: string }).name,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
);
async function switchLocale(code: LocaleCode, hide: () => void) {
await setLocale(code);
hide();
}
</script>

View File

@ -0,0 +1,4 @@
// This file is replaced on rebranding
export default {
SUPPORT_EMAIL: "devops@ocelot.social",
};

View File

@ -0,0 +1,14 @@
// this file is duplicated in `backend/src/config/metadata.js`, `webapp/constants/metadata.js` and `maintenance/app/constants/metadata.ts` and replaced on rebranding
export default {
APPLICATION_NAME: "ocelot.social",
APPLICATION_SHORT_NAME: "ocelot.social",
APPLICATION_DESCRIPTION: "ocelot.social Community Network",
ORGANIZATION_NAME: "ocelot.social Community",
ORGANIZATION_JURISDICTION: "City of Angels",
THEME_COLOR: "rgb(23, 181, 63)",
OG_IMAGE: "/img/custom/logo-squared.png",
OG_IMAGE_ALT: "ocelot.social Logo",
OG_IMAGE_WIDTH: "1200",
OG_IMAGE_HEIGHT: "1140",
OG_IMAGE_TYPE: "image/png",
};

View File

@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const LOCALES_DIR = resolve(__dirname, "../locales");
const WEBAPP_LOCALES_DIR = resolve(__dirname, "../../webapp/locales");
function loadJson(path: string): Record<string, unknown> {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
}
function getLocaleFiles(dir: string): string[] {
return readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""))
.sort();
}
function getKeys(obj: Record<string, unknown>, prefix = ""): string[] {
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null) {
keys.push(...getKeys(value as Record<string, unknown>, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
const maintenanceLocales = getLocaleFiles(LOCALES_DIR);
const webappLocales = getLocaleFiles(WEBAPP_LOCALES_DIR);
const referenceKeys = getKeys(loadJson(resolve(LOCALES_DIR, "en.json")));
describe("locales", () => {
it("has all webapp languages available", () => {
expect(maintenanceLocales).toEqual(webappLocales);
});
describe("completeness", () => {
for (const locale of maintenanceLocales) {
it(`${locale}.json has all keys from en.json`, () => {
const localeData = loadJson(resolve(LOCALES_DIR, `${locale}.json`));
const keys = getKeys(localeData);
expect(keys).toEqual(referenceKeys);
});
}
});
describe("no empty values", () => {
for (const locale of maintenanceLocales) {
it(`${locale}.json has no empty strings`, () => {
const content = readFileSync(
resolve(LOCALES_DIR, `${locale}.json`),
"utf-8",
);
expect(content).not.toMatch(/:\s*""\s*[,}]/);
});
}
});
});

View File

@ -0,0 +1,6 @@
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(FloatingVue);
});

View File

@ -0,0 +1,110 @@
import {
eslint as it4cEslint,
security,
comments,
json,
yaml,
css,
prettier,
typescript as it4cTypescript,
vue3 as it4cVue3,
importX as it4cImportX,
} from 'eslint-config-it4c'
import withNuxt from './.nuxt/eslint.config.mjs'
// it4c ESLint-Basisregeln extrahieren (recommended + custom, kein Plugin/Parser-Overlap mit Nuxt)
const it4cEslintRules = Object.assign({}, ...it4cEslint.map((c) => c.rules))
// it4c TypeScript-Regeln extrahieren (Plugin/Parser-Setup wird von Nuxt via tsconfigPath bereitgestellt)
const it4cTsRules = Object.assign({}, ...it4cTypescript.map((c) => c.rules))
// it4c Vue3-Regeln extrahieren (Plugin/Parser-Setup wird von Nuxt bereitgestellt)
const it4cVue3Rules = Object.assign({}, ...it4cVue3.map((c) => c.rules))
// it4c Import-X-Regeln extrahieren und auf Nuxt-Pluginname `import` umbenennen
const it4cImportRules = Object.fromEntries(
Object.entries(Object.assign({}, ...it4cImportX.map((c) => c.rules)))
.filter(([key]) => key.startsWith('import-x/'))
.map(([key, value]) => [key.replace('import-x/', 'import/'), value]),
)
// no-catch-all gehört nicht ins TypeScript-Modul
delete it4cTsRules['no-catch-all/no-catch-all']
export default withNuxt(
{ ignores: ['.nuxt/', '.claude/', '.output/', 'coverage/', 'eslint.config.ts', 'nuxt.config.ts', 'vitest.config.ts'] },
// it4c ESLint-Basisregeln
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,vue}'],
rules: it4cEslintRules,
},
{
rules: {
'no-unused-vars': 'off',
'no-undef': 'off',
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
// it4c Vue3-Regeln (nur vue/* Regeln, keine @typescript-eslint)
{
files: ['**/*.vue'],
rules: Object.fromEntries(
Object.entries(it4cVue3Rules).filter(([key]) => key.startsWith('vue/')),
),
},
// TypeScript type-checked rules are provided by @nuxt/eslint (strict: true)
// it4c TS rules are not used here because Nuxt's generated tsconfig scope
// doesn't cover all .ts files (plugins, constants, tests)
// it4c Import-Regeln
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,vue}'],
rules: it4cImportRules,
},
{
rules: {
'import/no-unresolved': 'off',
'import/no-named-as-default': 'off',
'import/no-relative-parent-imports': 'off',
'import/extensions': 'off',
'import/no-namespace': 'off',
},
},
{
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
// it4c-Module (self-contained)
...security,
{
rules: {
'security/detect-object-injection': 'off',
},
},
...comments,
...json,
...yaml,
...css,
{
files: ['**/*.css'],
rules: {
// text-rendering is valid in @font-face, @source is Tailwind v4
'css/no-invalid-at-rules': 'off',
// font-feature-settings: none is valid CSS
'css/no-invalid-properties': 'off',
},
},
{
files: ['**/*.spec.ts', '**/*.test.ts'],
rules: {
// Test helpers use dynamic file paths
'security/detect-non-literal-fs-filename': 'off',
},
},
// Prettier (MUSS letztes sein)
...prettier,
)
.override('nuxt/javascript', { ignores: ['**/*.css'] })

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Derzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuche es später erneut.",
"questions": "Bei Fragen oder Problemen erreichst du uns per E-Mail an",
"title": "{APPLICATION_NAME} befindet sich in der Wartung"
},
"localeSwitch": {
"tooltip": "Sprache wählen"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "At the moment we are doing some scheduled maintenance, please try again later.",
"questions": "If you have any questions or concerns, send an email to",
"title": "{APPLICATION_NAME} is under maintenance"
},
"localeSwitch": {
"tooltip": "Choose language"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Actualmente estamos llevando a cabo algunos trabajos de mantenimiento planificados, por favor, inténtelo de nuevo más tarde.",
"questions": "Si tiene alguna pregunta o problema, por favor contáctenos por correo electrónico a",
"title": "{APPLICATION_NAME} está en mantenimiento"
},
"localeSwitch": {
"tooltip": "Elegir idioma"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Pour l'instant, nous faisons de la maintenance programmée, veuillez réessayer plus tard.",
"questions": "Si vous avez des questions ou des préoccupations, envoyez un email à",
"title": "{APPLICATION_NAME} est en maintenance"
},
"localeSwitch": {
"tooltip": "Choisir la langue"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Al momento stiamo effettuando una manutenzione programmata, riprova più tardi.",
"questions": "Per domande o dubbi, invia un'email a",
"title": "{APPLICATION_NAME} è in manutenzione"
},
"localeSwitch": {
"tooltip": "Scegli lingua"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Op dit moment zijn we bezig met gepland onderhoud. Probeer het later opnieuw.",
"questions": "Vragen of opmerkingen? Stuur een e-mail naar",
"title": "{APPLICATION_NAME} is in onderhoud"
},
"localeSwitch": {
"tooltip": "Kies taal"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "W tej chwili przeprowadzamy planowaną konserwację. Spróbuj ponownie później.",
"questions": "Pytania lub wątpliwości? Wyślij e-mail na adres",
"title": "{APPLICATION_NAME} jest w trakcie konserwacji"
},
"localeSwitch": {
"tooltip": "Wybierz język"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "No momento estamos fazendo uma manutenção programada, por favor tente novamente mais tarde.",
"questions": "Se tiver dúvidas ou preocupações, envie um email para",
"title": "{APPLICATION_NAME} está em manutenção"
},
"localeSwitch": {
"tooltip": "Escolher idioma"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "В данный момент мы проводим плановое техническое обслуживание, пожалуйста, повторите попытку позже.",
"questions": "Любые вопросы или сообщения о проблемах отправляйте на электронную почту",
"title": "{APPLICATION_NAME} на техническом обслуживании"
},
"localeSwitch": {
"tooltip": "Выбрать язык"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Aktualisht po bëjmë mirëmbajtje të planifikuar. Ju lutem provoni përsëri më vonë.",
"questions": "Pyetje ose shqetësime? Dërgo një e-mail në",
"title": "{APPLICATION_NAME} është në mirëmbajtje"
},
"localeSwitch": {
"tooltip": "Zgjidh gjuhën"
}
}

View File

@ -0,0 +1,10 @@
{
"maintenance": {
"explanation": "Наразі ми проводимо планове технічне обслуговування, спробуйте пізніше.",
"questions": "Якщо у вас є питання або зауваження, надішліть електронний лист на",
"title": "{APPLICATION_NAME} на технічному обслуговуванні"
},
"localeSwitch": {
"tooltip": "Вибрати мову"
}
}

View File

@ -1,12 +1,14 @@
server {
listen 80;
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location ~* \.(?:css|js|map|jpe?g|gif|png|svg|woff|ico)$ { }
# Serve static assets directly
location ~* \.(?:css|js|map|jpe?g|gif|png|svg|woff2?|ico)$ { }
# All other requests return 503 maintenance page
location / {
if (-f $document_root/index.html) {
return 503;

View File

@ -0,0 +1,66 @@
import tailwindcss from "@tailwindcss/vite";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
ssr: false,
devServer: { host: "0.0.0.0" },
modules: ["@nuxt/eslint", "@nuxtjs/i18n"],
css: ["~/assets/css/branding.css", "~/assets/css/main.css", "@ocelot-social/ui/style.css"],
i18n: {
locales: [
{ code: "en", name: "English", file: "en.json" },
{ code: "de", name: "Deutsch", file: "de.json" },
{ code: "es", name: "Español", file: "es.json" },
{ code: "fr", name: "Français", file: "fr.json" },
{ code: "it", name: "Italiano", file: "it.json" },
{ code: "nl", name: "Nederlands", file: "nl.json" },
{ code: "pl", name: "Polski", file: "pl.json" },
{ code: "pt", name: "Português", file: "pt.json" },
{ code: "ru", name: "Русский", file: "ru.json" },
{ code: "sq", name: "Shqip", file: "sq.json" },
{ code: "uk", name: "Українська", file: "uk.json" },
],
defaultLocale: "en",
strategy: "no_prefix",
langDir: "../locales",
detectBrowserLanguage: {
useCookie: true,
cookieKey: "locale",
fallbackLocale: "en",
},
},
vite: {
plugins: [tailwindcss()],
optimizeDeps: {
// Pre-bundle for dev server (avoids re-processing minified code)
include: [
"@vue/devtools-core",
"@vue/devtools-kit",
"@ocelot-social/ui",
"@ocelot-social/ui/ocelot",
"floating-vue",
],
},
server: {
fs: {
allow: ["/packages/ui"],
},
},
build: {
rollupOptions: {
// Exclude pre-built UI library from Rollup re-bundling in production
// (minified tailwind-merge variable `h` collides with Vue's `h`)
external: [/^@ocelot-social\/ui/],
},
},
},
eslint: {
config: {
typescript: {
strict: true,
},
},
},
});

21001
maintenance/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
maintenance/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "maintenance",
"version": "3.15.1",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint ."
},
"dependencies": {
"@nuxtjs/i18n": "^10.2.4",
"@ocelot-social/ui": "file:../packages/ui",
"clsx": "^2.1.1",
"floating-vue": "^5.2.2",
"nuxt": "^4.4.2",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.30",
"vue-demi": "^0.14.10",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^4.3.0",
"@nuxt/eslint": "^1.15.2",
"@nuxt/test-utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.2",
"@vitest/coverage-v8": "^4.1.2",
"@vue/test-utils": "^2.4.6",
"eslint-config-it4c": "^0.12.0",
"happy-dom": "^20.8.9",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vitest": "^4.1.2",
"vue-tsc": "^3.2.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

View File

@ -0,0 +1,156 @@
#!/bin/bash
#
# Converts a webapp _branding.scss file to maintenance branding.css
#
# Usage:
# ./tools/convert-branding-scss.sh path/to/_branding.scss > app/assets/css/branding.css
#
# This tool resolves SCSS variable overrides from a branding file against
# the webapp's default tokens and generates a complete CSS branding file
# for the maintenance app.
#
# Prerequisites: sass (dart-sass) and the webapp directory must be available.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
WEBAPP_STYLES="$REPO_ROOT/webapp/assets/_new/styles"
BRANDING_FILE="${1:?Usage: $0 <path-to-_branding.scss>}"
if [ ! -f "$BRANDING_FILE" ]; then
echo "Error: File not found: $BRANDING_FILE" >&2
exit 1
fi
# Create a temporary SCSS file that imports tokens + branding override + variable mapping
TMPFILE=$(mktemp /tmp/branding-convert.XXXXXX.scss)
trap 'rm -f "$TMPFILE"' EXIT
cat > "$TMPFILE" << 'SCSS_HEADER'
@use "sass:color";
SCSS_HEADER
# Import default tokens
cat "$WEBAPP_STYLES/_styleguide-tokens.scss" >> "$TMPFILE"
cat "$WEBAPP_STYLES/tokens.scss" >> "$TMPFILE"
# Import branding overrides (these override the default SCSS variables)
cat "$BRANDING_FILE" >> "$TMPFILE"
# Generate CSS output
cat >> "$TMPFILE" << 'SCSS_TEMPLATE'
// ---- Generated CSS output ----
/* Fonts */
@font-face {
font-family: LatoWeb;
src: url("/fonts/Lato-Regular.1f440a46.woff2") format("woff2"),
url("/fonts/Lato-Regular.ffb25090.woff") format("woff");
font-style: normal;
font-weight: 400;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src: url("/fonts/Lato-Italic.a6774e2c.woff2") format("woff2"),
url("/fonts/Lato-Italic.ff8877c4.woff") format("woff");
font-style: italic;
font-weight: 400;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src: url("/fonts/Lato-Bold.1e239003.woff2") format("woff2"),
url("/fonts/Lato-Bold.35be9fc3.woff") format("woff");
font-style: normal;
font-weight: 600;
font-display: swap;
text-rendering: optimizelegibility;
}
@font-face {
font-family: LatoWeb;
src: url("/fonts/Lato-BoldItalic.4ef02877.woff2") format("woff2"),
url("/fonts/Lato-BoldItalic.5171ee7d.woff") format("woff");
font-style: italic;
font-weight: 600;
font-display: swap;
text-rendering: optimizelegibility;
}
html {
font-size: 15px;
}
body {
font-family: LatoWeb, sans-serif;
font-size: 15px;
line-height: 1.3;
margin: 0;
background-color: #{$color-neutral-90};
color: #{$text-color-base};
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: none;
font-variant-ligatures: none;
}
:root {
--color-default: #{$background-color-softer};
--color-default-hover: #{$color-neutral-70};
--color-default-active: #{$color-neutral-60};
--color-default-contrast: #{$text-color-base};
--color-default-contrast-inverse: #{$color-neutral-100};
--color-primary: #{$color-primary};
--color-primary-hover: #{$color-primary-light};
--color-primary-active: #{$color-primary-dark};
--color-primary-contrast: #{$text-color-primary-inverse};
--color-secondary: #{$color-secondary};
--color-secondary-hover: #{$color-secondary-active};
--color-secondary-active: #{darken($color-secondary, 15%)};
--color-secondary-contrast: #{$text-color-secondary-inverse};
--color-danger: #{$color-danger};
--color-danger-hover: #{$color-danger-light};
--color-danger-active: #{$color-danger-dark};
--color-danger-contrast: #{$text-color-danger-inverse};
--color-warning: #{$color-warning};
--color-warning-hover: #{$color-warning-active};
--color-warning-active: #{darken($color-warning, 15%)};
--color-warning-contrast: #{$text-color-primary-inverse};
--color-success: #{$color-success};
--color-success-hover: #{$color-success-active};
--color-success-active: #{darken($color-success, 15%)};
--color-success-contrast: #{$text-color-primary-inverse};
--color-info: #{$color-secondary};
--color-info-hover: #{$color-secondary-active};
--color-info-active: #{darken($color-secondary, 15%)};
--color-info-contrast: #{$text-color-secondary-inverse};
--color-disabled: #{$color-neutral-60};
--color-disabled-contrast: #{$color-neutral-100};
--color-text-base: #{$text-color-base};
--color-text-soft: #{$text-color-soft};
--color-background-soft: #{$color-neutral-95};
}
SCSS_TEMPLATE
# Compile SCSS to CSS — extract only the generated section (after the marker comment)
OUTPUT=$(npx sass --no-source-map --style=expanded "$TMPFILE" 2>/dev/null)
echo "/*"
echo " * Maintenance app branding — generated from: $(basename "$BRANDING_FILE")"
echo " * Generated by: tools/convert-branding-scss.sh"
echo " * To regenerate: ./tools/convert-branding-scss.sh $BRANDING_FILE > app/assets/css/branding.css"
echo " */"
echo ""
echo "$OUTPUT" | sed -n '/\/\* Fonts \*\//,$p'

View File

@ -0,0 +1,11 @@
#!/bin/bash
for locale in locales/*.json; do
file=$(basename "$locale")
if [ -f "locales/tmp/$file" ]; then
jq -s '.[0] * .[1]' "$locale" "locales/tmp/$file" > locales/tmp/tmp.json
mv locales/tmp/tmp.json "$locale"
fi
done
rm -r locales/tmp/

18
maintenance/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,20 @@
import path from "path";
import { defineVitestConfig } from "@nuxt/test-utils/config";
export default defineVitestConfig({
root: path.resolve(__dirname),
test: {
environment: "nuxt",
include: ["app/**/*.spec.ts"],
coverage: {
reporter: ["text", "json", "html"],
all: true,
include: ["app/**/*.{ts,vue}"],
exclude: ["app/**/*.spec.ts", "app/constants/**"],
thresholds: {
100: true,
},
},
},
});

View File

@ -35,6 +35,28 @@ Phase 4: Tier 2+ ██████████ 100% (OsModal✅, ds-form
| ✅ ds-select → OcelotSelect | Select (3 Dateien → OcelotSelect Webapp-Komponente, lokale Imports, click-outside inline) |
| ✅ → OsMenu/OsMenuItem | Menu, MenuItem (17 Nutzungen → packages/ui, dropdown Prop, eigene CSS) |
| ⬜ Nicht in Webapp | Code, CopyField, FormItem, InputError, InputLabel, Page, PageTitle, Logo, Avatar, TableCol, TableHeadCol (11) |
| ❌ Nicht geplant | OsLocaleSwitch — bricht Props-Only-Philosophie oder ist nur OsMenu-Wrapper (siehe Entscheidung unten) |
| ✅ Maintenance entkoppelt | Eigenständiges Nuxt 4-Projekt unter `maintenance/` — nutzt OsButton, OsIcon, OsCard aus packages/ui |
### Architektur-Entscheidungen
**OsLocaleSwitch: Nicht in packages/ui** (Session 34, 2026-03-27)
Evaluiert und abgelehnt. Eine LocaleSwitch-Komponente in der UI-Library würde entweder:
1. Die Props-Only-Philosophie brechen (i18n-Logik, Sprachnamen, Cookie-Handling eingebaut)
2. Oder nur ein triviales OsMenu-Wrapper sein (kein Mehrwert)
Stattdessen: Jede App baut ihre eigene LocaleSwitch mit UI-Library-Komponenten + app-spezifischer Logik.
**Maintenance-App: Entkopplung umgesetzt ✅** (Session 35, 2026-03-28)
Die Maintenance-App ist jetzt ein eigenständiges Nuxt 4-Projekt unter `maintenance/`:
- Eigene LocaleSwitch: OsButton (ghost/circle) + OsIcon (language) + floating-vue VDropdown
- @nuxtjs/i18n v10 mit 11 Sprachen, eigene Locale-Dateien
- Abhängigkeiten: @ocelot-social/ui, @nuxtjs/i18n, floating-vue, Tailwind CSS v4
- Kein Vuex, kein Apollo, kein v-tooltip — **vollständig von Webapp entkoppelt**
- Docker + nginx für statisches Hosting
- **Validiert packages/ui als echten Shared Layer** (erster externer Consumer)
### OsButton Migration (Phase 3) ✅

View File

@ -82,9 +82,9 @@ Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
Phase 4: █████████░ 85% (23/27 Aufgaben) - Tier 1 ✅, Tier A ✅, Infra ✅, OsBadge ✅, ds-grid ✅, ds-table→HTML ✅, OsNumber ✅, OsModal ✅, ds-radio→HTML ✅ | Tier B ✅, OcelotInput ✅, OcelotSelect ✅, OsMenu ✅ | 0 ds-* Tags verbleibend
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
Phase 5: ███░░░░░░░ 29% (2/7 Aufgaben) - Maintenance-App entkoppelt ✅
───────────────────────────────────────
Gesamt: █████████░ 89% (85/96 Aufgaben)
Gesamt: █████████░ 91% (87/96 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -216,11 +216,34 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
## Aktueller Stand
**Letzte Aktualisierung:** 2026-03-14 (Session 33)
**Letzte Aktualisierung:** 2026-03-28 (Session 35)
**Aktuelle Phase:** Phase 4 - Tier 1 ✅, Tier A ✅, Tier B ✅, OsModal ✅ | Tier 2-3 ausstehend
**Aktuelle Phase:** Phase 5 gestartet — Maintenance-App entkoppelt ✅ | Weitere Phase 5 Aufgaben ausstehend
**Zuletzt abgeschlossen (Session 33 - ds-radio → native HTML):**
**Zuletzt abgeschlossen (Session 35 - Maintenance-App Entkopplung):**
- [x] Maintenance-App als eigenständiges Nuxt 4-Projekt unter `maintenance/` aufgesetzt
- Vue 3 + Nuxt 4 (ssr: false, statisches HTML via `nuxt generate`)
- `@ocelot-social/ui` als Dependency (OsButton, OsIcon, OsCard)
- `@nuxtjs/i18n` v10 mit 11 Sprachen (en, de, es, fr, it, nl, pl, pt, ru, sq, uk)
- `floating-vue` (VDropdown) für LocaleSwitch-Dropdown
- Tailwind CSS v4 mit `@tailwindcss/vite`
- Eigene LocaleSwitch-Komponente: OsButton (ghost/circle) + OsIcon (language) + VDropdown
- Eigene Locale-Dateien unter `maintenance/locales/`
- Eigene Branding/Design-Tokens unter `maintenance/app/assets/css/`
- Dockerfile für Docker-Build
- nginx-Konfiguration für statisches Hosting
- vitest + @nuxt/test-utils + @vue/test-utils für Tests
- ESLint mit eslint-config-it4c + @intlify/eslint-plugin-vue-i18n
- Kein Vuex, kein Apollo, kein v-tooltip — vollständig entkoppelt von Webapp
- [x] packages/ui bleibt i18n-frei (Props-Only-Philosophie bestätigt)
**Zuvor abgeschlossen (Session 34 - Architektur-Entscheidungen):**
- [x] Styleguide-Ablösung evaluiert: system.css nicht mehr importiert, Styleguide kein Submodul
- [x] Verbleibende ds-CSS-Klassen (~50 Nutzungen) über `_ds-compat.scss` abgedeckt
- [x] Entscheidung: Keine `OsLocaleSwitch` in packages/ui — bricht Props-Only-Philosophie oder ist nur glorifiziertes OsMenu
- [x] Entscheidung: Maintenance-App als eigenständiges Projekt entkoppeln
**Zuvor abgeschlossen (Session 33 - ds-radio → native HTML):**
- [x] `<ds-radio>` in ReportModal.vue → native `<fieldset>` + `<input type="radio">` + `<label>`
- [x] Accessible Radio-Group: `<fieldset>` mit `<legend>` für Screen-Reader
- [x] CSS: `.report-radio-group`, `.report-radio-option`, `.report-radio-option-label` (in ReportModal `<style>`)
@ -408,18 +431,13 @@ ds-chip + ds-tag → OsBadge (UI-Library): ✅
- [x] Session 11: Wasserfarben-Farbschema, Stories konsolidiert, Keyboard A11y
**Nächste Schritte:**
- [x] OsSpinner Webapp-Migration (DsSpinner + LoadingSpinner → OsSpinner) ✅
- [x] OsCard Komponente + BaseCard → OsCard Webapp-Migration ✅
- [x] Tier A: 10 triviale ds-* Wrapper → Plain HTML + CSS ✅
- [x] OsBadge Komponente + ds-chip/ds-tag → OsBadge Webapp-Migration ✅
- [x] OsNumber Komponente + ds-number/CountTo → OsNumber Webapp-Migration ✅
- [ ] Tier B (Rest): ds-radio → Plain HTML
- [x] OsModal Komponente + DsModal/ConfirmModal/ReportModal → OsModal Webapp-Integration ✅
- [ ] Weitere Tier 2 Komponenten (OsDropdown, OsAvatar)
- [x] ds-form → formValidation Mixin (async-validator), 18 Dateien migriert, vuelidate entfernt ✅
- [x] ds-input → OcelotInput (23 Dateien, Webapp-Komponente mit lokalen Imports, formValidation-kompatibel) ✅
- [x] ds-menu / ds-menu-item → OsMenu / OsMenuItem (packages/ui, 17 Nutzungen in 11 Dateien, dropdown Prop, eigene CSS in index.css) ✅
- [x] ds-select → OcelotSelect (3 Dateien, Webapp-Komponente mit lokalen Imports, click-outside inline) ✅
- [x] **Maintenance-App entkoppelt** ✅ (eigenständiges Nuxt 4-Projekt unter `maintenance/`)
- [x] Eigene LocaleSwitch: OsButton + OsIcon + floating-vue VDropdown (kein Vuex/Apollo/v-tooltip)
- [x] Eigenständige i18n-Konfiguration (@nuxtjs/i18n v10, 11 Sprachen)
- [x] Abhängigkeit nur auf @ocelot-social/ui + floating-vue + Design-Tokens
- [x] Eigener Build (Nuxt 4 generate → statisches HTML + nginx + Docker)
- [ ] Verbleibende ds-CSS-Klassen ablösen (`_ds-compat.scss` → eigene Utility-Klassen oder Tailwind)
- [ ] Weitere Tier 2 Komponenten bei Bedarf (OsDropdown, OsAvatar)
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
**Manuelle Setup-Aufgaben (außerhalb Code):**
@ -712,7 +730,11 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
> **Hinweis:** ds-heading, ds-text, ds-tag wurden zu Plain HTML migriert (Tier A).
> OsHeading/OsText/OsTag als UI-Library-Komponenten sind daher nicht mehr geplant.
### Phase 5: Finalisierung
### Phase 5: Finalisierung & Entkopplung
- [x] **Maintenance-App als eigenständiges Projekt** ✅ (Erste Validierung der Library-Unabhängigkeit)
- [x] Eigene LocaleSwitch (OsButton + OsIcon + floating-vue VDropdown, kein Vuex/Apollo)
- [x] Eigenständiger Build ohne Webapp-Abhängigkeiten (Nuxt 4 + Docker + nginx)
- [x] Nur @ocelot-social/ui + @nuxtjs/i18n + floating-vue + Design-Tokens
- [ ] Alle Komponenten migriert und getestet
- [ ] Alte Komponenten aus Vue 2 Projekt entfernt
- [ ] Build als npm Library verifiziert
@ -1855,6 +1877,7 @@ Bei der Migration werden:
| 2026-03-23 | **OcelotInput: ds-icon → os-icon** | DsIcon durch OsIcon + resolveIcon() ersetzt. at.svg, envelope.svg, paperclip.svg zu Ocelot-Icons hinzugefügt. Ocelot-Icons Visual Snapshot aktualisiert. |
| 2026-03-23 | **ds-select → OcelotSelect** | Neue Webapp-Komponente `OcelotSelect.vue`: vereint DsSelect + inputMixin + multiinputMixin (~420 Zeilen). Form-Validation entfernt (von keinem Consumer genutzt). DsChip→OsBadge, DsSpinner→OsSpinner, DsIcon→OsIcon. vue-click-outside durch inline document.addEventListener ersetzt. 3 Dateien migriert, 16 Tests ✅. |
| 2026-03-23 | **ds-menu → OsMenu/OsMenuItem** | Neue packages/ui Komponenten: h() Render, vue-demi, provide/inject, dropdown Prop für Popup-Variante. CSS in src/styles/index.css (integriert in style.css Build). 17 Nutzungen in 11 Dateien migriert. Action-Menüs nutzen link-tag default 'a' statt router-link. router-link Stub global in testSetup.js. Vite closeBundle Hook: ui.css in style.css gemergt. 273 UI-Tests, 108 Webapp-Tests ✅. **0 ds-* Komponenten-Tags verbleibend in Webapp.** |
| 2026-03-28 | **Maintenance-App entkoppelt (Session 35)** | Eigenständiges Nuxt 4-Projekt unter `maintenance/`. Vue 3, SSR off, `nuxt generate` → statisches HTML. Abhängigkeiten: @ocelot-social/ui (OsButton, OsIcon, OsCard), @nuxtjs/i18n v10 (11 Sprachen), floating-vue (VDropdown für LocaleSwitch), Tailwind CSS v4. Eigene LocaleSwitch-Komponente (OsButton ghost/circle + OsIcon language + VDropdown). Eigene Locale-Dateien, Branding-CSS, Docker + nginx. Tests: vitest + @nuxt/test-utils. Kein Vuex/Apollo/v-tooltip — **vollständig von Webapp entkoppelt. Validiert packages/ui als echten Shared Layer.** |
---
@ -2291,86 +2314,53 @@ Vor dem Erstellen einer Komponente diese Fragen beantworten:
## 16a. Webapp ↔ Maintenance Code-Sharing
### Problemstellung
### Problemstellung (historisch)
Die Webapp und Maintenance-App sind aktuell verschachtelt und sollen getrennt werden.
Einige Business-Komponenten werden in beiden Apps benötigt, gehören aber nicht in die UI-Library.
Die Webapp und Maintenance-App waren verschachtelt (`webapp/maintenance/`). Die Frage war, wie geteilte Business-Komponenten organisiert werden sollten.
**Das DX-Problem:** "shared" hat kein logisches Kriterium außer "wird in beiden gebraucht".
### Umgesetzte Lösung: Vollständige Entkopplung ✅ (Session 35, 2026-03-28)
### Analysierte Optionen
| Option | Beschreibung | Bewertung |
|--------|--------------|-----------|
| **A: Domain Packages** | `@ocelot-social/auth`, `@ocelot-social/posts`, etc. | Gut bei vielen Komponenten, aber Overhead |
| **B: Core + Duplikation** | Composables teilen, Komponenten duplizieren | Gut wenn UI unterschiedlich |
| **C: Webapp als Source** | Maintenance importiert aus Webapp | Einfachste Lösung |
### Empfehlung: Option C (Webapp als Source of Truth)
Die Maintenance-App ist eine rein statische Wartungsseite ohne Business-Logik. Daher wurde statt Option C (Webapp als Source) die einfachste Lösung gewählt: **Vollständige Entkopplung ohne Webapp-Imports.**
```
┌─────────────────────────────────────────────────────────────┐
@ocelot-social/ui │
│ ───────────────── │
│ • OsButton, OsModal, OsCard, OsInput │
│ • OsButton, OsIcon, OsCard, OsModal, OsBadge, OsNumber, │
│ OsSpinner, OsMenu, OsMenuItem │
│ • Rein präsentational, keine Abhängigkeiten │
├─────────────────────────────────────────────────────────────┤
│ webapp/ │
│ ─────── │
│ • Alle Business-Komponenten (Source of Truth) │
│ • Composables in webapp/lib/composables/ │
│ • GraphQL in webapp/graphql/ │
│ • Ist die "Haupt-App" │
│ • Nuxt 2 (Vue 2.7), importiert @ocelot-social/ui │
│ • Alle Business-Komponenten + GraphQL + Vuex │
│ • OcelotInput, OcelotSelect (lokale Webapp-Komponenten) │
├─────────────────────────────────────────────────────────────┤
│ maintenance/
│ maintenance/ ← NEU: eigenständig
│ ──────────── │
│ • Importiert aus @ocelot-social/ui │
│ • Importiert aus webapp/ via Alias │
│ • Nur maintenance-spezifische Komponenten lokal │
│ • Nuxt 4 (Vue 3), importiert @ocelot-social/ui │
│ • Keine Imports aus webapp/ — vollständig unabhängig │
│ • Eigene LocaleSwitch (OsButton + OsIcon + floating-vue) │
│ • @nuxtjs/i18n v10, eigene Locale-Dateien │
│ • Tailwind CSS v4, Docker + nginx │
└─────────────────────────────────────────────────────────────┘
```
### Umsetzung
**maintenance/nuxt.config.js:**
```javascript
export default {
alias: {
'@webapp': '../webapp',
'@ocelot-social/ui': '../packages/ui/dist'
}
}
```
**Import in Maintenance:**
```typescript
// UI-Komponenten aus Library
import { OsButton, OsModal } from '@ocelot-social/ui'
// Business-Komponenten aus Webapp
import FollowButton from '@webapp/components/FollowButton.vue'
import PostTeaser from '@webapp/components/PostTeaser.vue'
// Composables aus Webapp
import { useAuth } from '@webapp/lib/composables/useAuth'
import { useFollow } from '@webapp/lib/composables/useFollow'
```
### Kriterien für Entwickler
| Frage | Antwort |
|-------|---------|
| Wo suche ich eine UI-Komponente? | `@ocelot-social/ui` |
| Wo suche ich eine UI-Komponente? | `@ocelot-social/ui` (packages/ui) |
| Wo suche ich eine Business-Komponente? | `webapp/components/` |
| Wo erstelle ich eine neue geteilte Komponente? | `webapp/components/` |
| Wo erstelle ich maintenance-spezifische Komponenten? | `maintenance/components/` |
| Wo erstelle ich maintenance-spezifische Komponenten? | `maintenance/app/components/` |
| Teilen Webapp und Maintenance Code? | Nein — nur @ocelot-social/ui als Shared Layer |
### Vorteile
### Vorteile der Entkopplung
1. **Klare Regel:** Alles Business-bezogene ist in Webapp
2. **Kein neues Package:** Weniger Overhead
3. **Eine Source of Truth:** Keine Sync-Probleme
4. **Einfache Migration:** Später ggf. Domain-Packages extrahieren
1. **Keine Webapp-Abhängigkeit:** Maintenance kann unabhängig gebaut und deployed werden
2. **Validiert packages/ui:** Erster externer Consumer — beweist Library-Unabhängigkeit
3. **Moderner Stack:** Vue 3 + Nuxt 4 + Tailwind v4 (Webapp noch Vue 2.7 + Nuxt 2)
4. **Einfacher Build:** `nuxt generate` → statisches HTML, keine API-Abhängigkeiten
### Spätere Evolution (optional)

View File

@ -36,12 +36,17 @@ ul.os-menu-list {
.os-menu-item-link {
display: block;
width: 100%;
box-sizing: border-box;
color: var(--color-text-base, #4b4554);
line-height: 1.3;
text-decoration: none;
text-align: start;
padding: 8px 16px;
border: none;
border-left: 2px solid transparent;
background: none;
font-size: inherit;
transition: color 80ms ease-out,
background-color 80ms ease-out,
border-left-color 80ms ease-out;

View File

@ -15,8 +15,8 @@ cd backend
yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $VERSION_NEW
cd $ROOT_DIR/webapp
yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $VERSION_NEW
cd $ROOT_DIR/webapp/maintenance/source
yarn version --no-git-tag-version --no-commit-hooks --no-commit --new-version $VERSION_NEW
cd $ROOT_DIR/maintenance
npm version --no-git-tag-version $VERSION_NEW
## helm
sed -i -e 's/appVersion: ".*"/appVersion: "'"$VERSION_NEW"'"/g' $ROOT_DIR/deployment/helm/charts/ocelot-neo4j/Chart.yaml

View File

@ -1,42 +0,0 @@
# syntax=docker/dockerfile:1
FROM nginx:1.29.7-alpine AS base
LABEL org.label-schema.name="ocelot.social:maintenance"
LABEL org.label-schema.description="Maintenance page of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/webapp"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
FROM node:25.8.2-alpine AS ui-library
RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
COPY packages/ui .
RUN npm ci
RUN npm run build
FROM node:25.8.2-alpine AS build
ENV NODE_ENV="production"
RUN apk --no-cache add git python3 make g++ bash jq
COPY --from=ui-library ./app/ /packages/ui/
RUN mkdir -p /app
WORKDIR /app
COPY webapp/ .
# Delete all Pages
RUN rm -rf ./pages
# branding is unified in /branding in branding repositories
ONBUILD COPY webap[p]/brandin[g]/ .
ONBUILD COPY brandin[g]/ .
ONBUILD RUN tools/merge-locales.sh
ONBUILD RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
ONBUILD RUN cp -r maintenance/source/* ./
ONBUILD RUN yarn run generate
FROM build AS production_build
FROM base AS production
COPY --from=production_build ./app/dist/ /usr/share/nginx/html/
COPY --from=production_build ./app/maintenance/nginx/custom.conf /etc/nginx/conf.d/default.conf

View File

@ -1,40 +0,0 @@
# Maintenance Mode
The maintenance mode shows a translatable page that tells the user that we are right back, because we are working on the server.
![Maintenance Mode Screen Shot](../../.gitbook/assets/maintenance-page.png)
## Running The Maintenance Page Or Service
At the moment the maintenance mode can only be locally tested with Docker-Compose.
::: tabs
@tab:active Locally Without Docker
{% hint style="info" %}
TODO: Implement a locally running maintenance mode! Without Docker …
{% endhint %}
The command …
```bash
# running the maintenance mode in webapp/ folder
$ yarn generate:maintenance
```
… is unfortunatelly **not(!)** working at the moment.
This is because the code is rewritten to be easy usable for Docker-Compose. Therefore we lost this possibility.
@tab Locally With Docker
To get the maintenance mode running use the command:
```bash
# start Docker in the main folder
$ docker-compose up
````
And the maintenance mode page or service will be started as well in an own container.
In the browser you can reach it under `http://localhost:3001/`.
:::

View File

@ -1,65 +0,0 @@
import defaultConfig from './nuxt.config.js'
const { css, styleResources, manifest, build, mode, buildModules } = defaultConfig
const CONFIG = require('./config').default // we need to use require since this is only evaluated at compile time.
export default {
mode,
env: CONFIG,
head: {
title: manifest.name,
meta: [
{
charset: 'utf-8',
},
{
name: 'viewport',
content: 'initial-scale=1',
},
{
hid: 'description',
name: 'description',
content: `Maintenance page for ${manifest.name}`,
},
],
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon.ico',
},
],
},
css,
styleResources,
plugins: [
{ src: '~/plugins/i18n.js', ssr: true },
{ src: '~/plugins/v-tooltip.js', ssr: false },
],
router: {
extendRoutes(routes, resolve) {
routes.push({
name: 'maintenance',
path: '*',
component: resolve(__dirname, 'pages/index.vue'),
})
},
},
modules: [
['@nuxtjs/dotenv', { only: Object.keys(CONFIG) }],
['nuxt-env', { keys: Object.keys(CONFIG) }],
'cookie-universal-nuxt',
'@nuxtjs/style-resources',
],
buildModules,
manifest,
build,
}

View File

@ -1,16 +0,0 @@
{
"name": "@ocelot-social/maintenance",
"version": "3.15.1",
"description": "Maintenance page for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
"license": "MIT",
"private": false,
"main": "index.js",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt -c nuxt.config.maintenance.js",
"build": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt build -c nuxt.config.maintenance.js",
"start": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt start -c nuxt.config.maintenance.js",
"generate": "cross-env NODE_OPTIONS=--openssl-legacy-provider yarn run nuxt generate -c nuxt.config.maintenance.js"
}
}

View File

@ -1,90 +0,0 @@
<template>
<transition name="fade" appear>
<div class="ds-container ds-container-medium">
<os-card>
<div class="ds-mb-large">
<locale-switch class="login-locale-switch" offset="5" />
</div>
<div class="ds-flex maintenance-layout">
<div class="maintenance-layout__image">
<div class="ds-mb-large">
<!-- QUESTION: could we have internal page or even all internal pages here as well with PageParamsLink by having the footer underneath? -->
<!-- I tried this out, but only could get the nginx page displayed. I guees the there were nuxt errors, because the nuxt config file 'webapp/maintenance/source/nuxt.config.maintenance.js' would have to be refactored for that as well and may be the missing folder `components/_new/generic/` plays a role, see https://github.com/Ocelot-Social-Community/Ocelot-Social/pull/4619 -->
<!-- <page-params-link :pageParams="links.ORGANIZATION" :title="$t('login.moreInfo', metadata)">
<logo type="maintenance" />
</page-params-link> -->
<!-- BUT: not the logo and not even the a-tag is working at the moment -->
<!-- <a
:href="emails.ORGANIZATION_LINK"
:title="$t('login.moreInfo', metadata)"
target="_blank"
> -->
<img
class="image"
:alt="$t('maintenance.title', metadata)"
src="/img/custom/logo-squared.svg"
/>
<!-- </a> -->
</div>
</div>
<div class="maintenance-layout__content">
<div>
<h3 class="ds-heading ds-heading-h3">{{ $t('maintenance.title', metadata) }}</h3>
</div>
<div>
<div class="ds-my-small">
<p class="ds-text">{{ $t('maintenance.explanation') }}</p>
<p class="ds-text">
{{ $t('maintenance.questions') }}
<a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>
</p>
</div>
</div>
</div>
</div>
</os-card>
</div>
</transition>
</template>
<script>
import { OsCard } from '@ocelot-social/ui'
import emails from '~/constants/emails.js'
// import links from '~/constants/links.js'
import metadata from '~/constants/metadata.js'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
// import Logo from '~/components/Logo/Logo'
export default {
layout: 'blank',
components: {
OsCard,
LocaleSwitch,
// Logo,
},
data() {
// return { links, metadata, supportEmail: emails.SUPPORT_EMAIL }
return { metadata, supportEmail: emails.SUPPORT_EMAIL }
},
}
</script>
<style lang="scss" scoped>
.image {
width: 75%;
height: auto;
}
.maintenance-layout__image,
.maintenance-layout__content {
flex: 0 0 100%;
width: 100%;
}
@media #{$media-query-small} {
.maintenance-layout__image {
flex: 1 0 0;
}
.maintenance-layout__content {
flex: 1 0 0;
}
}
</style>