cleanup pass

This commit is contained in:
Matt Beaudoin
2025-11-09 11:07:20 -08:00
parent 6d0478c3df
commit 371f4fdebc
18 changed files with 974 additions and 186 deletions

View File

@ -2,5 +2,5 @@
This is the frontend of a web wrapper for Coop Clouds abra CLI, letting users set up and manage VPSs and docker images through a web UI.
## This is built with react, typescript, and vite
## This is built with react, typescript, scss, and vite

View File

@ -1,27 +1,50 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist']),
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// TypeScript specific
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/no-explicit-any': 'warn',
// Import organization
'sort-imports': ['warn', {
ignoreCase: true,
ignoreDeclarationSort: true,
}],
// React best practices
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Code quality
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'warn',
'no-var': 'error',
},
},
])
);

View File

@ -1,14 +1,19 @@
{
"name": "coopcloud-frontend",
"name": "coopcloud-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix",
"format": "prettier --write 'src/**/*.{ts,tsx,scss,css,json}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx,scss,css,json}'"
},
"dependencies": {
"react": "^19.1.1",
@ -25,7 +30,11 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"sass": "^1.93.3",
"stylelint": "^16.25.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"

689
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,8 @@
// App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { LoginForm } from './routes/Login/LoginForm';
import { Authenticated } from './routes/Login/Authenticated';
import HomePage from './components/HomePage';
import { Dashboard } from './routes/Authenticated/Dashboard/Dashboard';
function App() {
return (
@ -19,7 +17,7 @@ function App() {
path="/dashboard"
element={
<Authenticated>
<HomePage />
<Dashboard />
</Authenticated>
}
/>

View File

@ -1,5 +1,3 @@
// Reusable SCSS mixins
@use 'variables' as *;
// Flexbox center

View File

@ -1,5 +1,3 @@
// Design tokens - colors, spacing, typography
// Colors
$primary: #EFEFEF;
$primary-dark: #ff4f88;

View File

@ -1,108 +0,0 @@
// components/HomePage.tsx
import React, { useEffect, useState } from 'react';
import { Dashboard } from '../routes/Authenticated/Dashboard/Dashboard'
import { apiService } from '../services/api';
import type { AbraApp, AbraServer } from '../types';
// import './HomePage.scss';
const HomePage: React.FC = () => {
const [apps, setApps] = useState<AbraApp[]>([]);
const [servers, setServers] = useState<AbraServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
// Use mock API in development
const { mockApiService } = await import('../services/mockApi');
const [appsData, serversData] = await Promise.all([
mockApiService.getApps(),
mockApiService.getServers(),
]);
setApps(appsData);
setServers(serversData);
} else {
// Use real API in production
const [appsData, serversData] = await Promise.all([
apiService.getApps(),
apiService.getServers(),
]);
setApps(appsData);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
if (loading) {
return (
<Dashboard>
<div className="loading">Loading dashboard...</div>
</Dashboard>
);
}
if (error) {
return (
<Dashboard>
<div className="error">Error: {error}</div>
</Dashboard>
);
}
return (
<Dashboard>
<div className="homepage">
<h2>Dashboard</h2>
<div className="stats-grid">
<div className="stat-card">
<h3>Apps</h3>
<p className="stat-number">{apps.length}</p>
<p className="stat-label">
{apps.filter(a => a.status === 'running').length} running
</p>
</div>
<div className="stat-card">
<h3>Servers</h3>
<p className="stat-number">{servers.length}</p>
<p className="stat-label">
{servers.filter(s => s.connected).length} connected
</p>
</div>
</div>
<section className="recent-apps">
<h3>Recent Applications</h3>
<div className="apps-list">
{apps.slice(0, 5).map((app) => (
<div key={app.id} className="app-item">
<div className="app-info">
<h4>{app.name}</h4>
<p className="app-domain">{app.domain}</p>
</div>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</div>
))}
</div>
</section>
</div>
</Dashboard>
);
};
export default HomePage;

View File

@ -1,8 +1,6 @@
// context/AuthContext.tsx
import React, { createContext, useState, useEffect, ReactNode } from 'react';
import React, { createContext, ReactNode, useEffect, useState } from 'react';
import { apiService } from '../services/api';
import type { User, LoginCredentials } from '../types';
import type { LoginCredentials, User } from '../types';
interface AuthContextType {
user: User | null;

View File

@ -1,5 +1,3 @@
// hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';

View File

@ -1,28 +1,110 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../../hooks/useAuth';
import {Header} from '../../../components/Header/Header'
import React, { useEffect, useState } from 'react';
import { Header } from '../../../components/Header/Header';
import { apiService } from '../../../services/api';
import type { AbraApp, AbraServer } from '../../../types';
import './_Dashboard.scss';
interface DashboardProps {
children: React.ReactNode;
}
export const Dashboard: React.FC = () => {
const [apps, setApps] = useState<AbraApp[]>([]);
const [servers, setServers] = useState<AbraServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
export const Dashboard: React.FC<DashboardProps> = ({ children }) => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const isMockMode = import.meta.env.VITE_MOCK_AUTH === 'true';
const handleLogout = async () => {
await logout();
navigate('/login');
};
useEffect(() => {
const fetchData = async () => {
try {
if (isMockMode) {
// Use mock API in development
const { mockApiService } = await import('../../../services/mockApi');
const [appsData, serversData] = await Promise.all([
mockApiService.getApps(),
mockApiService.getServers(),
]);
setApps(appsData);
setServers(serversData);
} else {
// Use real API in production
const [appsData, serversData] = await Promise.all([
apiService.getApps(),
apiService.getServers(),
]);
setApps(appsData);
setServers(serversData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
fetchData();
}, [isMockMode]);
if (loading) {
return (
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="loading">Loading dashboard...</div>
</main>
</div>
);
}
if (error) {
return (
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<div className="error">Error: {error}</div>
</main>
</div>
);
}
return (
<div className="layout">
<Header></Header>
<div className="dashboard-page">
<Header />
<main className="dashboard-content">
<h2>Dashboard</h2>
<div className="stats-grid">
<div className="stat-card">
<h3>Apps</h3>
<p className="stat-number">{apps.length}</p>
<p className="stat-label">
{apps.filter(a => a.status === 'running').length} running
</p>
</div>
<main className="layout-main">
{children}
<div className="stat-card">
<h3>Servers</h3>
<p className="stat-number">{servers.length}</p>
<p className="stat-label">
{servers.filter(s => s.connected).length} connected
</p>
</div>
</div>
<section className="recent-apps">
<h3>Recent Applications</h3>
<div className="apps-list">
{apps.slice(0, 5).map((app) => (
<div key={app.id} className="app-item">
<div className="app-info">
<h4>{app.name}</h4>
<p className="app-domain">{app.domain}</p>
</div>
<span className={`status-badge status-${app.status}`}>
{app.status}
</span>
</div>
))}
</div>
</section>
</main>
</div>
);

View File

@ -1,25 +1,136 @@
@use '../../../assets/scss/variables' as *;
@use '../../../assets/scss/mixins' as *;
.layout {
.dashboard-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: $bg-secondary;
}
.layout-main {
flex: 1;
.dashboard-content {
max-width: 1400px;
margin: 0 auto;
padding: $spacing-2xl $spacing-xl;
width: 100%;
box-sizing: border-box;
@media (max-width: 768px) {
padding: $spacing-xl $spacing-md;
}
@media (max-width: 480px) {
padding: $spacing-lg $spacing-sm;
h2 {
margin: 0 0 $spacing-2xl;
font-size: $font-size-3xl;
color: $text-primary;
}
}
.loading, .error {
text-align: center;
padding: $spacing-3xl;
font-size: $font-size-lg;
color: $text-secondary;
}
.error {
color: $error;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: $spacing-lg;
margin-bottom: $spacing-2xl;
}
.stat-card {
@include card;
border-left: 4px solid $primary;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
}
h3 {
margin: 0 0 $spacing-md;
color: $text-secondary;
font-size: $font-size-sm;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: $font-weight-semibold;
}
.stat-number {
margin: 0 0 $spacing-sm;
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $text-primary;
line-height: 1;
}
.stat-label {
margin: 0;
color: $text-muted;
font-size: $font-size-sm;
}
}
.recent-apps {
h3 {
margin: 0 0 $spacing-lg;
font-size: $font-size-2xl;
color: $text-primary;
}
}
.apps-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.app-item {
@include card;
padding: $spacing-lg;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-lg;
cursor: pointer;
}
.app-info {
h4 {
margin: 0 0 $spacing-xs;
font-size: $font-size-lg;
color: $text-primary;
font-weight: $font-weight-semibold;
}
.app-domain {
margin: 0;
color: $text-muted;
font-size: $font-size-sm;
}
}
.status-badge {
@include status-badge($success);
text-transform: capitalize;
&.status-running {
@include status-badge($success);
}
&.status-stopped {
@include status-badge($text-muted);
}
&.status-error {
@include status-badge($error);
}
}
}

View File

@ -1,5 +1,3 @@
// components/auth/Authenticated.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';

View File

@ -1,4 +1,3 @@
// components/auth/LoginForm.scss
@use '../../assets/scss/variables' as *;
@use '../../assets/scss/mixins' as *;
@ -6,7 +5,8 @@
@include flex-center;
min-height: 100vh;
width: 100%;
@include gradient-primary;
// @include gradient-primary;
background-color: $primary-dark;
position: fixed;
top: 0;
left: 0;

View File

@ -1,5 +1,3 @@
// components/auth/LoginForm.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';

View File

@ -1,6 +1,4 @@
// services/api.ts
import type { AuthResponse, LoginCredentials, User, AbraApp, AbraServer } from '../types';
import type { AbraApp, AbraServer, AuthResponse, LoginCredentials, User } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

View File

@ -1,6 +1,6 @@
import type { AbraApp, AbraServer } from '../types';
// Mock data for development
// Mock dev data
export const mockApps: AbraApp[] = [
{
id: '1',

View File

@ -1,5 +1,3 @@
// types/index.ts
export interface User {
id: string;
username: string;