Move old project and create new Flask + Next.js + MUI stack

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-08 22:46:46 +00:00
parent fd6af0c86b
commit a6f6c841cd
106 changed files with 8939 additions and 16 deletions

80
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Build and Push to GHCR
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME_BACKEND: ${{ github.repository }}-backend
IMAGE_NAME_FRONTEND: ${{ github.repository }}-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BACKEND }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
- name: Extract metadata for frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_FRONTEND }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push frontend image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
build-args: |
NEXT_PUBLIC_API_URL=http://backend:5000

20
.gitignore vendored
View File

@@ -25,6 +25,7 @@ dist-ssr
*.sw?
.env
.env.local
**/agent-eval-report*
packages
pids
@@ -32,3 +33,22 @@ pids
.devcontainer/
.spark-workbench-id
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
*.egg-info/
.pytest_cache/
# Next.js
.next/
out/
build/
.vercel

211
README.md
View File

@@ -1,23 +1,202 @@
# ✨ Welcome to Your Spark Template!
You've just launched your brand-new Spark Template Codespace — everythings fired up and ready for you to explore, build, and create with Spark!
# Docker Swarm Container Terminal WebUI
This template is your blank canvas. It comes with a minimal setup to help you get started quickly with Spark development.
A modern, secure web interface for managing Docker containers with interactive terminal access.
🚀 What's Inside?
- A clean, minimal Spark environment
- Pre-configured for local development
- Ready to scale with your ideas
🧠 What Can You Do?
## Features
Right now, this is just a starting point — the perfect place to begin building and testing your Spark applications.
- 🔐 **Secure Authentication** - Username/password authentication to protect container access
- 📦 **Container Management** - View all active containers with real-time status updates
- 💻 **Interactive Terminal** - Execute commands directly in running containers
- 🎨 **Modern UI** - Built with Material UI and Next.js for a polished experience
- 🐳 **Docker Native** - Direct integration with Docker API
- 📱 **Responsive Design** - Works seamlessly on desktop and mobile devices
🧹 Just Exploring?
No problem! If you were just checking things out and dont need to keep this code:
## Tech Stack
- Simply delete your Spark.
- Everything will be cleaned up — no traces left behind.
### Frontend
- **Next.js 14+** - React framework with App Router
- **Material UI (MUI)** - Modern component library
- **TypeScript** - Type-safe development
- **Tailwind CSS** - Utility-first styling
📄 License For Spark Template Resources
### Backend
- **Flask** - Python web framework
- **Docker SDK** - Python Docker integration
- **Flask-CORS** - Cross-origin resource sharing
The Spark Template files and resources from GitHub are licensed under the terms of the MIT license, Copyright GitHub, Inc.
### Deployment
- **Docker** - Containerized deployment
- **Docker Compose** - Multi-container orchestration
- **GHCR** - GitHub Container Registry for images
## Quick Start
### Using Docker Compose (Recommended)
1. Clone the repository:
```bash
git clone https://github.com/johndoe6345789/docker-swarm-termina.git
cd docker-swarm-termina
```
2. Start the application:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:3000
- Backend API: http://localhost:5000
4. Login with default credentials:
- Username: `admin`
- Password: `admin123`
### Manual Setup
#### Backend Setup
1. Navigate to the backend directory:
```bash
cd backend
```
2. Install Python dependencies:
```bash
pip install -r requirements.txt
```
3. Create `.env` file (optional):
```bash
cp .env.example .env
# Edit .env to set custom credentials
```
4. Run the Flask application:
```bash
python app.py
```
The backend will be available at http://localhost:5000
#### Frontend Setup
1. Navigate to the frontend directory:
```bash
cd frontend
```
2. Install Node.js dependencies:
```bash
npm install
```
3. Create `.env.local` file:
```bash
cp .env.local.example .env.local
# Edit if backend is running on a different URL
```
4. Run the development server:
```bash
npm run dev
```
The frontend will be available at http://localhost:3000
## Configuration
### Backend Environment Variables
- `ADMIN_USERNAME` - Admin username (default: `admin`)
- `ADMIN_PASSWORD` - Admin password (default: `admin123`)
- `FLASK_ENV` - Flask environment (default: `development`)
### Frontend Environment Variables
- `NEXT_PUBLIC_API_URL` - Backend API URL (default: `http://localhost:5000`)
## API Endpoints
### Authentication
- `POST /api/auth/login` - Authenticate user
- `POST /api/auth/logout` - Logout user
### Containers
- `GET /api/containers` - List all containers
- `POST /api/containers/<id>/exec` - Execute command in container
### Health
- `GET /api/health` - Health check endpoint
## Docker Images
Images are automatically built and pushed to GitHub Container Registry on every push to main:
- Backend: `ghcr.io/johndoe6345789/docker-swarm-termina-backend`
- Frontend: `ghcr.io/johndoe6345789/docker-swarm-termina-frontend`
## Security Considerations
⚠️ **Important Security Notes:**
1. **Change Default Credentials** - Always change the default admin credentials in production
2. **Docker Socket Access** - The backend requires access to Docker socket (`/var/run/docker.sock`)
3. **Network Security** - Use proper firewall rules and network isolation
4. **HTTPS** - Use HTTPS in production (reverse proxy recommended)
5. **Session Management** - Current implementation uses simple token-based auth; consider implementing JWT or OAuth for production
## Development
### Project Structure
```
.
├── backend/ # Flask backend
│ ├── app.py # Main Flask application
│ ├── requirements.txt # Python dependencies
│ └── Dockerfile # Backend Docker image
├── frontend/ # Next.js frontend
│ ├── app/ # Next.js app directory
│ ├── components/ # React components
│ ├── lib/ # Utility functions and API client
│ └── Dockerfile # Frontend Docker image
├── old/ # Previous version (React + Vite)
├── docker-compose.yml # Docker Compose configuration
└── .github/
└── workflows/
└── docker-publish.yml # GHCR publishing workflow
```
### Building for Production
#### Build Docker Images
```bash
docker-compose build
```
#### Push to GHCR
Images are automatically pushed when code is merged to main. To manually push:
```bash
docker tag docker-swarm-termina-backend ghcr.io/johndoe6345789/docker-swarm-termina-backend:latest
docker tag docker-swarm-termina-frontend ghcr.io/johndoe6345789/docker-swarm-termina-frontend:latest
docker push ghcr.io/johndoe6345789/docker-swarm-termina-backend:latest
docker push ghcr.io/johndoe6345789/docker-swarm-termina-frontend:latest
```
## Old Version
The previous version (React + Vite + Radix UI) is preserved in the `old/` directory for reference.
## License
MIT License - see [LICENSE](LICENSE) file for details
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Support
For issues, questions, or contributions, please open an issue on GitHub.

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Flask Backend Environment Variables
FLASK_APP=app.py
FLASK_ENV=development
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "app.py"]

59
backend/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Backend - Flask API
Python Flask backend for Docker container management.
## Features
- RESTful API for container management
- Docker SDK integration
- Session-based authentication
- CORS enabled for frontend access
## Setup
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Configure environment (optional):
```bash
cp .env.example .env
# Edit .env with your settings
```
3. Run the server:
```bash
python app.py
```
The server will start on http://localhost:5000
## API Endpoints
### Authentication
- `POST /api/auth/login` - Login with username/password
- `POST /api/auth/logout` - Logout current session
### Containers
- `GET /api/containers` - List all containers (requires auth)
- `POST /api/containers/<id>/exec` - Execute command in container (requires auth)
### Health
- `GET /api/health` - Health check
## Docker
Build the Docker image:
```bash
docker build -t docker-swarm-backend .
```
Run the container:
```bash
docker run -p 5000:5000 -v /var/run/docker.sock:/var/run/docker.sock docker-swarm-backend
```
## Security
⚠️ This backend requires access to the Docker socket. Ensure proper security measures are in place in production environments.

145
backend/app.py Normal file
View File

@@ -0,0 +1,145 @@
from flask import Flask, jsonify, request
from flask_cors import CORS
import docker
import os
from datetime import datetime, timedelta
app = Flask(__name__)
CORS(app)
# Simple in-memory session storage (in production, use proper session management)
sessions = {}
# Default credentials (should be environment variables in production)
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin123')
def get_docker_client():
"""Get Docker client"""
try:
return docker.from_env()
except Exception as e:
print(f"Error connecting to Docker: {e}")
return None
def format_uptime(created_at):
"""Format container uptime"""
created = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
now = datetime.now(created.tzinfo)
delta = now - created
days = delta.days
hours = delta.seconds // 3600
minutes = (delta.seconds % 3600) // 60
if days > 0:
return f"{days}d {hours}h"
elif hours > 0:
return f"{hours}h {minutes}m"
else:
return f"{minutes}m"
@app.route('/api/auth/login', methods=['POST'])
def login():
"""Authenticate user"""
data = request.get_json()
username = data.get('username')
password = data.get('password')
if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
# Create a simple session token (in production, use JWT or proper session management)
session_token = f"session_{username}_{datetime.now().timestamp()}"
sessions[session_token] = {
'username': username,
'created_at': datetime.now()
}
return jsonify({
'success': True,
'token': session_token,
'username': username
})
return jsonify({
'success': False,
'message': 'Invalid credentials'
}), 401
@app.route('/api/auth/logout', methods=['POST'])
def logout():
"""Logout user"""
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
if token in sessions:
del sessions[token]
return jsonify({'success': True})
@app.route('/api/containers', methods=['GET'])
def get_containers():
"""Get list of all containers"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
containers = client.containers.list(all=True)
container_list = []
for container in containers:
container_list.append({
'id': container.short_id,
'name': container.name,
'image': container.image.tags[0] if container.image.tags else 'unknown',
'status': container.status,
'uptime': format_uptime(container.attrs['Created']) if container.status == 'running' else 'N/A'
})
return jsonify({'containers': container_list})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/containers/<container_id>/exec', methods=['POST'])
def exec_container(container_id):
"""Execute command in container"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
token = auth_header.split(' ')[1]
if token not in sessions:
return jsonify({'error': 'Invalid session'}), 401
data = request.get_json()
command = data.get('command', '/bin/sh')
client = get_docker_client()
if not client:
return jsonify({'error': 'Cannot connect to Docker'}), 500
try:
container = client.containers.get(container_id)
exec_instance = container.exec_run(command, stdout=True, stderr=True, stdin=True, tty=True)
return jsonify({
'output': exec_instance.output.decode('utf-8') if exec_instance.output else '',
'exit_code': exec_instance.exit_code
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'healthy'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask==3.0.0
Flask-CORS==4.0.0
python-dotenv==1.0.0
docker==7.0.0

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "5000:5000"
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=admin123
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=http://localhost:5000
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://backend:5000
depends_on:
- backend
restart: unless-stopped

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

49
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Container,
Typography,
Button,
Grid,
AppBar,
Toolbar,
IconButton,
CircularProgress,
} from '@mui/material';
import { Logout, Refresh, Inventory2 } from '@mui/icons-material';
import { useAuth } from '@/lib/auth';
import { apiClient, Container as ContainerType } from '@/lib/api';
import ContainerCard from '@/components/ContainerCard';
import TerminalModal from '@/components/TerminalModal';
export default function Dashboard() {
const { isAuthenticated, loading: authLoading, logout } = useAuth();
const router = useRouter();
const [containers, setContainers] = useState<ContainerType[]>([]);
const [selectedContainer, setSelectedContainer] = useState<ContainerType | null>(null);
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, authLoading, router]);
const fetchContainers = async () => {
setIsRefreshing(true);
setError('');
try {
const data = await apiClient.getContainers();
setContainers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch containers');
if (err instanceof Error && err.message === 'Session expired') {
router.push('/');
}
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
};
useEffect(() => {
if (isAuthenticated) {
fetchContainers();
const interval = setInterval(fetchContainers, 10000);
return () => clearInterval(interval);
}
}, [isAuthenticated]);
const handleOpenShell = (container: ContainerType) => {
setSelectedContainer(container);
setIsTerminalOpen(true);
};
const handleCloseTerminal = () => {
setIsTerminalOpen(false);
setTimeout(() => setSelectedContainer(null), 300);
};
const handleLogout = async () => {
await logout();
router.push('/');
};
const handleRefresh = () => {
fetchContainers();
};
if (authLoading || isLoading) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', backgroundColor: 'background.default' }}>
<AppBar
position="sticky"
sx={{
backgroundColor: 'rgba(45, 55, 72, 0.5)',
backdropFilter: 'blur(8px)',
borderBottom: 1,
borderColor: 'divider',
}}
>
<Toolbar>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexGrow: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Inventory2 sx={{ color: 'secondary.main' }} />
</Box>
<Box>
<Typography
variant="h1"
sx={{ fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem' }}
>
Container Shell
</Typography>
<Typography variant="caption" color="text.secondary">
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
startIcon={isRefreshing ? <CircularProgress size={16} /> : <Refresh />}
>
Refresh
</Button>
<Button
variant="outlined"
size="small"
onClick={handleLogout}
startIcon={<Logout />}
>
Logout
</Button>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="xl" sx={{ py: 4 }}>
{error && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'error.dark', borderRadius: 1 }}>
<Typography color="error.contrastText">{error}</Typography>
</Box>
)}
{containers.length === 0 && !isLoading ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
textAlign: 'center',
}}
>
<Box
sx={{
width: 80,
height: 80,
backgroundColor: 'action.hover',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<Inventory2 sx={{ fontSize: 40, color: 'text.secondary' }} />
</Box>
<Typography variant="h2" gutterBottom>
No Active Containers
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 500 }}>
There are currently no running containers to display. Start a container to see it
appear here.
</Typography>
</Box>
) : (
<Grid container spacing={3}>
{containers.map((container) => (
<Grid item xs={12} sm={6} lg={4} key={container.id}>
<ContainerCard
container={container}
onOpenShell={() => handleOpenShell(container)}
/>
</Grid>
))}
</Grid>
)}
</Container>
{selectedContainer && (
<TerminalModal
open={isTerminalOpen}
onClose={handleCloseTerminal}
containerName={selectedContainer.name}
containerId={selectedContainer.id}
/>
)}
</Box>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

33
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/lib/theme";
import { AuthProvider } from "@/lib/auth";
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Container Shell - Docker Swarm Terminal",
description: "Docker container management terminal web UI",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={jetbrainsMono.variable}>
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
}

23
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import LoginForm from '@/components/LoginForm';
export default function Home() {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, loading, router]);
if (loading) {
return null;
}
return <LoginForm />;
}

View File

@@ -0,0 +1,162 @@
'use client';
import React from 'react';
import {
Card,
CardContent,
Typography,
Button,
Box,
Chip,
Divider,
} from '@mui/material';
import { Terminal, PlayArrow, Inventory2 } from '@mui/icons-material';
import { Container } from '@/lib/api';
interface ContainerCardProps {
container: Container;
onOpenShell: () => void;
}
export default function ContainerCard({ container, onOpenShell }: ContainerCardProps) {
const statusColors = {
running: 'success',
stopped: 'default',
paused: 'warning',
} as const;
const borderColors = {
running: '#38b2ac',
stopped: '#718096',
paused: '#ecc94b',
};
return (
<Card
sx={{
borderLeft: 4,
borderColor: borderColors[container.status as keyof typeof borderColors] || borderColors.stopped,
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start', flex: 1 }}>
<Box
sx={{
width: 40,
height: 40,
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Inventory2 sx={{ color: 'secondary.main', fontSize: 20 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="h3"
component="h3"
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{container.name}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{container.image}
</Typography>
</Box>
</Box>
<Chip
label={container.status}
color={statusColors[container.status as keyof typeof statusColors] || 'default'}
size="small"
icon={container.status === 'running' ? <PlayArrow sx={{ fontSize: 12 }} /> : undefined}
sx={{
fontFamily: '"JetBrains Mono", monospace',
textTransform: 'capitalize',
}}
/>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 3 }}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Container ID
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{container.id}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 0.5,
}}
>
Uptime
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: '"JetBrains Mono", monospace' }}
>
{container.uptime}
</Typography>
</Box>
</Box>
<Button
fullWidth
variant="contained"
color="primary"
onClick={onOpenShell}
disabled={container.status !== 'running'}
startIcon={<Terminal />}
sx={{
fontWeight: 500,
'&:hover': {
backgroundColor: 'secondary.main',
},
}}
>
Open Shell
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import React, { useState } from 'react';
import {
Card,
CardContent,
TextField,
Button,
Typography,
Box,
Alert,
} from '@mui/material';
import { LockOpen } from '@mui/icons-material';
import { useAuth } from '@/lib/auth';
import { useRouter } from 'next/navigation';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isShaking, setIsShaking] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = await login(username, password);
if (success) {
router.push('/dashboard');
} else {
setError('Invalid credentials');
setIsShaking(true);
setTimeout(() => setIsShaking(false), 500);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)',
padding: 2,
}}
>
<Card
sx={{
width: '100%',
maxWidth: 400,
animation: isShaking ? 'shake 0.5s' : 'none',
'@keyframes shake': {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-10px)' },
'75%': { transform: 'translateX(10px)' },
},
}}
>
<CardContent sx={{ p: 4 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
sx={{
width: 64,
height: 64,
margin: '0 auto 16px',
background: 'rgba(56, 178, 172, 0.1)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<LockOpen sx={{ fontSize: 32, color: 'secondary.main' }} />
</Box>
<Typography variant="h1" component="h1" gutterBottom>
Container Shell
</Typography>
<Typography variant="body2" color="text.secondary">
Enter your credentials to access container management
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
required
autoComplete="username"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
autoComplete="current-password"
sx={{ mb: 3 }}
/>
<Button
type="submit"
fullWidth
variant="contained"
color="secondary"
size="large"
sx={{ mb: 2 }}
>
Access Dashboard
</Button>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
textAlign: 'center',
pt: 2,
borderTop: 1,
borderColor: 'divider',
}}
>
Default: admin / admin123
</Typography>
</form>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
IconButton,
Paper,
} from '@mui/material';
import { Close, Send } from '@mui/icons-material';
import { apiClient } from '@/lib/api';
interface TerminalModalProps {
open: boolean;
onClose: () => void;
containerName: string;
containerId: string;
}
export default function TerminalModal({
open,
onClose,
containerName,
containerId,
}: TerminalModalProps) {
const [command, setCommand] = useState('');
const [output, setOutput] = useState<string[]>([]);
const [isExecuting, setIsExecuting] = useState(false);
const handleExecute = async () => {
if (!command.trim()) return;
setIsExecuting(true);
setOutput((prev) => [...prev, `$ ${command}`]);
try {
const result = await apiClient.executeCommand(containerId, command);
setOutput((prev) => [...prev, result.output || '(no output)']);
} catch (error) {
setOutput((prev) => [...prev, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`]);
} finally {
setIsExecuting(false);
setCommand('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleExecute();
}
};
const handleClose = () => {
setOutput([]);
setCommand('');
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
minHeight: '500px',
maxHeight: '80vh',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
pb: 2,
}}
>
<Typography variant="h2" component="div">
Terminal - {containerName}
</Typography>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Paper
elevation={0}
sx={{
backgroundColor: '#0d1117',
color: '#c9d1d9',
fontFamily: '"JetBrains Mono", monospace',
fontSize: '0.875rem',
padding: 2,
minHeight: '300px',
maxHeight: '400px',
overflowY: 'auto',
mb: 2,
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#161b22',
},
'&::-webkit-scrollbar-thumb': {
background: '#30363d',
borderRadius: '4px',
},
}}
>
{output.length === 0 ? (
<Typography color="text.secondary" sx={{ fontFamily: 'inherit' }}>
Connected to {containerName}. Enter a command to start...
</Typography>
) : (
<Box component="pre" sx={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{output.join('\n')}
</Box>
)}
</Paper>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter command (e.g., ls, pwd, echo 'hello')"
disabled={isExecuting}
variant="outlined"
size="small"
sx={{
fontFamily: '"JetBrains Mono", monospace',
'& input': {
fontFamily: '"JetBrains Mono", monospace',
},
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleExecute}
disabled={isExecuting || !command.trim()}
startIcon={<Send />}
>
Execute
</Button>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
Close
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

117
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,117 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
export interface Container {
id: string;
name: string;
image: string;
status: string;
uptime: string;
}
export interface AuthResponse {
success: boolean;
token?: string;
username?: string;
message?: string;
}
export interface ContainersResponse {
containers: Container[];
}
class ApiClient {
private token: string | null = null;
setToken(token: string | null) {
this.token = token;
if (token) {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
}
}
getToken(): string | null {
if (!this.token && typeof window !== 'undefined') {
this.token = localStorage.getItem('auth_token');
}
return this.token;
}
async login(username: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success && data.token) {
this.setToken(data.token);
}
return data;
}
async logout(): Promise<void> {
const token = this.getToken();
if (token) {
await fetch(`${API_BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
}
this.setToken(null);
}
async getContainers(): Promise<Container[]> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 401) {
this.setToken(null);
throw new Error('Session expired');
}
throw new Error('Failed to fetch containers');
}
const data: ContainersResponse = await response.json();
return data.containers;
}
async executeCommand(containerId: string, command: string): Promise<any> {
const token = this.getToken();
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`${API_BASE_URL}/api/containers/${containerId}/exec`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
});
if (!response.ok) {
throw new Error('Failed to execute command');
}
return response.json();
}
}
export const apiClient = new ApiClient();

70
frontend/lib/auth.tsx Normal file
View File

@@ -0,0 +1,70 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { apiClient } from './api';
interface AuthContextType {
isAuthenticated: boolean;
username: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user has a valid token
const token = apiClient.getToken();
if (token) {
setIsAuthenticated(true);
// In a real app, you'd validate the token with the backend
}
setLoading(false);
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await apiClient.login(username, password);
if (response.success) {
setIsAuthenticated(true);
setUsername(response.username || username);
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = async () => {
try {
await apiClient.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
setIsAuthenticated(false);
setUsername(null);
}
};
return (
<AuthContext.Provider value={{ isAuthenticated, username, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

90
frontend/lib/theme.tsx Normal file
View File

@@ -0,0 +1,90 @@
'use client';
import { createTheme, ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#4a5568', // slate
light: '#718096',
dark: '#2d3748',
},
secondary: {
main: '#38b2ac', // cyan/teal
light: '#4fd1c5',
dark: '#319795',
},
background: {
default: '#1a202c',
paper: '#2d3748',
},
text: {
primary: '#f7fafc',
secondary: '#cbd5e0',
},
success: {
main: '#38b2ac',
},
},
typography: {
fontFamily: '"JetBrains Mono", "Courier New", monospace',
h1: {
fontWeight: 700,
fontSize: '2rem',
letterSpacing: '-0.02em',
},
h2: {
fontWeight: 600,
fontSize: '1.5rem',
},
h3: {
fontWeight: 500,
fontSize: '1.125rem',
},
body1: {
fontSize: '0.875rem',
lineHeight: 1.6,
},
button: {
fontWeight: 500,
textTransform: 'none',
},
},
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: '8px',
transition: 'all 0.15s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(56, 178, 172, 0.1)',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: '6px',
padding: '8px 16px',
transition: 'all 0.1s ease',
'&:active': {
transform: 'scale(0.98)',
},
},
},
},
},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
);
}

7
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

7103
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

Some files were not shown because too many files have changed in this diff Show More