From 0e4a2e18190ec0091a2c82030cedf7cfa5594311 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Fri, 10 Apr 2026 20:29:40 +0300 Subject: [PATCH] feat(app): add environment import/export and sidebar footer info - add environment import/export API endpoints and client integration - add environment export actions and toolbar import/export buttons in UI - move theme switcher to sidebar bottom and show root package version - switch snippet card menu icon to cog for consistent options affordance --- client/src/App.module.css | 22 ++++++++ client/src/App.tsx | 36 +++++++------ client/src/api/client.ts | 9 ++++ client/src/declarations.d.ts | 2 + client/src/i18n/locales/en.json | 2 + .../environment/EnvironmentDetailPage.tsx | 19 ++++++- .../pages/environment/EnvironmentsPage.tsx | 51 ++++++++++++++++++- client/src/pages/snippet/SnippetsPage.tsx | 4 +- client/vite.config.ts | 15 ++++++ server/package.json | 1 + .../environment/dto/environment-export.dto.ts | 31 +++++++++++ .../src/environment/environment.controller.ts | 17 +++++++ server/src/environment/environment.service.ts | 29 +++++++++++ 13 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 server/src/environment/dto/environment-export.dto.ts diff --git a/client/src/App.module.css b/client/src/App.module.css index ded25a4..ca00bb2 100644 --- a/client/src/App.module.css +++ b/client/src/App.module.css @@ -30,6 +30,28 @@ padding: var(--space-2) 0; } +.sideContent { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.sideFooter { + margin-top: auto; + padding: var(--space-3) var(--space-2) var(--space-2); + border-top: var(--border-width) solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.version { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + white-space: nowrap; +} + .navItem { display: flex; align-items: center; diff --git a/client/src/App.tsx b/client/src/App.tsx index a60dca5..5a93ba4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -47,25 +47,31 @@ export default function App() { header={
Liqa -
} width={220} > - +
+ + +
+ +
v{__APP_VERSION__}
+
+
diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81f98a7..31b4971 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -133,6 +133,15 @@ export const environments = { remove(id: string): Promise { return request(`/environments/${id}`, { method: 'DELETE' }); }, + exportEnvironment(id: string): Promise { + return request(`/environments/${id}/export`); + }, + importEnvironment(payload: unknown): Promise { + return request('/environments/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, }; // ── Sessions ────────────────────────────────────────────────────────────────── diff --git a/client/src/declarations.d.ts b/client/src/declarations.d.ts index 65bd2fc..122ba9c 100644 --- a/client/src/declarations.d.ts +++ b/client/src/declarations.d.ts @@ -6,3 +6,5 @@ declare module '*.css' { interface ImportMetaEnv { readonly VITE_API_URL?: string; } + +declare const __APP_VERSION__: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b963b61..dbd5d27 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -31,6 +31,8 @@ "action_delete": "Delete", "action_add": "Add environment", "action_edit": "Edit", + "action_export": "Export", + "action_import": "Import", "action_save": "Create", "action_update": "Save", "action_cancel": "Cancel", diff --git a/client/src/pages/environment/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx index 62efa23..70c1eba 100644 --- a/client/src/pages/environment/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Pencil, Trash2, Globe } from 'lucide-react'; +import { Upload, Pencil, Trash2, Globe } from 'lucide-react'; +import { stringify as yamlStringify } from 'yaml'; import { environments } from '../../api'; import type { Environment } from '../../api'; import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; @@ -30,6 +31,18 @@ export function EnvironmentDetailPage() { navigate('/environments'); }; + const handleExport = async () => { + if (!env) return; + const data = await environments.exportEnvironment(env.id); + const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `environment-${env.name}.yaml`; + a.click(); + URL.revokeObjectURL(url); + }; + return (
@@ -41,6 +54,10 @@ export function EnvironmentDetailPage() { /> {env && (
+ +
{error &&

{error}

} {loading ? ( diff --git a/client/src/pages/snippet/SnippetsPage.tsx b/client/src/pages/snippet/SnippetsPage.tsx index b1d1fe8..d9ed9fd 100644 --- a/client/src/pages/snippet/SnippetsPage.tsx +++ b/client/src/pages/snippet/SnippetsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Code, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react'; +import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react'; import { parse as yamlParse } from 'yaml'; import { snippets } from '../../api'; import type { Snippet } from '../../api'; @@ -20,7 +20,7 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s align="right" trigger={ } items={[ diff --git a/client/vite.config.ts b/client/vite.config.ts index 8830a2c..08ad0fe 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,14 +1,29 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { playwright } from '@vitest/browser-playwright'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const rootPackageJsonPath = path.resolve(dirname, '../package.json'); +const APP_VERSION = (() => { + try { + const json = JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf8')) as { + version?: string; + }; + return json.version ?? 'dev'; + } catch { + return 'dev'; + } +})(); // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(APP_VERSION), + }, plugins: [react()], server: { proxy: { diff --git a/server/package.json b/server/package.json index 86d18f1..3dda9ea 100644 --- a/server/package.json +++ b/server/package.json @@ -1,5 +1,6 @@ { "name": "liqa-server", + "version": "1.0.0", "main": "index.js", "scripts": { "build": "nest build", diff --git a/server/src/environment/dto/environment-export.dto.ts b/server/src/environment/dto/environment-export.dto.ts new file mode 100644 index 0000000..5c1f1bf --- /dev/null +++ b/server/src/environment/dto/environment-export.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsIn, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; +import { EnvironmentUrls } from "../environment.entity"; + +export class EnvironmentExportDto { + @ApiPropertyOptional() + @IsOptional() + @IsIn(["environment"]) + kind?: "environment"; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + id?: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsObject() + urls: EnvironmentUrls; +} diff --git a/server/src/environment/environment.controller.ts b/server/src/environment/environment.controller.ts index a345a9f..8b70f40 100644 --- a/server/src/environment/environment.controller.ts +++ b/server/src/environment/environment.controller.ts @@ -14,6 +14,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { EnvironmentService } from "./environment.service"; import { CreateEnvironmentDto } from "./dto/create-environment.dto"; import { UpdateEnvironmentDto } from "./dto/update-environment.dto"; +import { EnvironmentExportDto } from "./dto/environment-export.dto"; import { PaginationQueryDto } from "../common/dto/pagination.dto"; import { EnvironmentOrderBy } from "./environment.service"; @@ -30,6 +31,13 @@ export class EnvironmentController { return this.environmentService.create(dto); } + @Post("import") + @ApiOperation({ summary: "Import an environment (upsert by id or name)" }) + @ApiResponse({ status: 201, description: "Environment imported" }) + async importEnvironment(@Body() dto: EnvironmentExportDto) { + return this.environmentService.importEnvironment(dto); + } + @Get() @ApiOperation({ summary: "List all environments (paginated)" }) @ApiResponse({ status: 200, description: "Paginated environments" }) @@ -37,6 +45,15 @@ export class EnvironmentController { return this.environmentService.findAll(query); } + @Get(":id/export") + @ApiOperation({ summary: "Export an environment as a plain object" }) + @ApiResponse({ status: 200, description: "Environment export payload" }) + @ApiResponse({ status: 404, description: "Environment not found" }) + async exportEnvironment(@Param("id", ParseUUIDPipe) id: string) { + const env = await this.environmentService.findOne(id); + return this.environmentService.exportEnvironment(env); + } + @Get(":id") @ApiOperation({ summary: "Get environment by ID" }) @ApiResponse({ status: 200, description: "Environment record" }) diff --git a/server/src/environment/environment.service.ts b/server/src/environment/environment.service.ts index 0063aa3..b5bf9f2 100644 --- a/server/src/environment/environment.service.ts +++ b/server/src/environment/environment.service.ts @@ -8,6 +8,7 @@ import { Repository } from "typeorm"; import { EnvironmentEntity } from "./environment.entity"; import { CreateEnvironmentDto } from "./dto/create-environment.dto"; import { UpdateEnvironmentDto } from "./dto/update-environment.dto"; +import { EnvironmentExportDto } from "./dto/environment-export.dto"; import { PaginationQueryDto, PaginatedResult, @@ -64,4 +65,32 @@ export class EnvironmentService { await this.findOne(id); await this.repo.delete(id); } + + exportEnvironment(env: EnvironmentEntity): EnvironmentExportDto { + return { + kind: "environment", + id: env.id, + name: env.name, + urls: env.urls, + }; + } + + async importEnvironment(dto: EnvironmentExportDto): Promise { + if (dto.id) { + const existing = await this.repo.findOneBy({ id: dto.id }); + if (existing) { + Object.assign(existing, { name: dto.name, urls: dto.urls }); + return this.repo.save(existing); + } + return this.repo.save( + this.repo.create({ id: dto.id, name: dto.name, urls: dto.urls }), + ); + } + const byName = await this.repo.findOneBy({ name: dto.name }); + if (byName) { + Object.assign(byName, { urls: dto.urls }); + return this.repo.save(byName); + } + return this.repo.save(this.repo.create({ name: dto.name, urls: dto.urls })); + } }