diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..e0d94ae --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,40 @@ +# GitHub Release Workflow + +This document describes the GitHub Actions workflow for releasing the Garage UI project. + +## How to Create a Release + +1. Create a new tag following semantic versioning and push it to GitHub: + ```bash + git tag v1.0.0 + git push origin v1.0.0 + ``` + +2. The workflow will automatically create a GitHub release and build everything + +## What the Workflow Does + +When a new tag is pushed, the workflow will: + +1. Extract the version from the tag (e.g., v1.0.0 becomes 1.0.0) +2. Build the Docker image and push it to GitHub Container Registry with appropriate version tags +3. Build binaries for various platforms (Linux with architectures: 386, amd64, arm, arm64) using the tag version +4. Create a GitHub release and attach the binaries as assets + +## Docker Images + +The Docker images will be available at: +- `ghcr.io/adekabang/garage-ui:latest` +- `ghcr.io/adekabang/garage-ui:X.Y.Z` (version tag) +- `ghcr.io/adekabang/garage-ui:X.Y` (major.minor tag) + +## Binaries + +The binaries will be attached to the GitHub release and can be downloaded directly from the release page. + +## Configuration + +If you want to also push Docker images to Docker Hub, uncomment and configure the Docker Hub login section in the workflow file and add the following secrets to your repository: + +- `DOCKERHUB_USERNAME`: Your Docker Hub username +- `DOCKERHUB_TOKEN`: Your Docker Hub access token diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..645ac3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,156 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DOCKER_HUB_REGISTRY: adekabang/garage-webui + +jobs: + build-and-push-image: + name: Build and push Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Optional: Log in to Docker Hub if you want to push there too + # Uncomment and configure secrets in your GitHub repository settings + # - name: Log in to Docker Hub + # uses: docker/login-action@v3 + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} + # + # - name: Add Docker Hub as additional image target + # if: ${{ success() && secrets.DOCKERHUB_USERNAME != '' }} + # run: | + # docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_version.outputs.VERSION }} ${{ env.DOCKER_HUB_REGISTRY }}:${{ steps.get_version.outputs.VERSION }} + # docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_version.outputs.VERSION }} ${{ env.DOCKER_HUB_REGISTRY }}:latest + # docker push ${{ env.DOCKER_HUB_REGISTRY }}:${{ steps.get_version.outputs.VERSION }} + # docker push ${{ env.DOCKER_HUB_REGISTRY }}:latest + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.get_version.outputs.VERSION }} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.get_version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-binaries: + name: Build binaries + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Set up PNPM + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Install dependencies + run: pnpm install + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Create version.txt file with tag version + run: echo "${{ steps.get_version.outputs.VERSION }}" > version.txt + + - name: Create custom build script for tag-based versioning + run: | + cat > misc/tag-build.sh << 'EOF' + #!/bin/sh + + set -e + + BINARY=garage-webui + VERSION=$(cat version.txt) + PLATFORMS="linux" + ARCHITECTURES="386 amd64 arm arm64" + + echo "Building version $VERSION" + + pnpm run build + cd backend && rm -rf dist && mkdir -p dist && rm -rf ./ui/dist && cp -r ../dist ./ui/dist + + for PLATFORM in $PLATFORMS; do + for ARCH in $ARCHITECTURES; do + echo "Building $PLATFORM-$ARCH" + + GOOS=$PLATFORM GOARCH=$ARCH go build -o "dist/$BINARY-v$VERSION-$PLATFORM-$ARCH" -tags="prod" main.go + done + done + EOF + + chmod +x misc/tag-build.sh + + - name: Build binaries + run: | + ./misc/tag-build.sh + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + name: Release ${{ steps.get_version.outputs.VERSION }} + draft: false + prerelease: false + files: | + backend/dist/garage-webui-* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index c128b51..28a65e0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,4 @@ dist-ssr .env* !.env.example docker-compose.*.yml - -data/ -meta/ -garage.toml +!docker-compose.dev.yml diff --git a/LICENSE b/LICENSE index 302d07b..762991a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Khairul Hidayat +Copyright (c) 2024-2025 Khairul Hidayat +Copyright (c) 2025 Mohammad Raska (Adekabang) - Garage Web UI v2 upgrade Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ed0c42e..a959334 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ [![image](misc/img/garage-webui.png)](misc/img/garage-webui.png) -A simple admin web UI for [Garage](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service. +A modern admin web UI for [Garage v2](https://garagehq.deuxfleurs.fr/), a self-hosted, S3-compatible, distributed object storage service. + +> **Note**: This is version 2.0.0 of Garage Web UI, designed to work with Garage v2. If you're using Garage v1, please use the [v1.x release](https://github.com/khairul169/garage-webui/releases/tag/1.0.9) of the Web UI instead. + +This project is based on [khairul169/garage-webui](https://github.com/khairul169/garage-webui), the original Garage Web UI project. The v2 upgrade is maintained by khairul169. [ [Screenshots](misc/SCREENSHOTS.md) | [Install Garage](https://garagehq.deuxfleurs.fr/documentation/quick-start/) | [Garage Git](https://git.deuxfleurs.fr/Deuxfleurs/garage) ] @@ -21,7 +25,7 @@ The Garage Web UI is available as a single executable binary and docker image. Y ### Docker CLI ```sh -$ docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui khairul169/garage-webui:latest +docker run -p 3909:3909 -v ./garage.toml:/etc/garage.toml:ro --restart unless-stopped --name garage-webui ghcr.io/khairul169/garage-webui:latest ``` ### Docker Compose @@ -45,7 +49,7 @@ services: - 3903:3903 webui: - image: khairul169/garage-webui:latest + image: ghcr.io/khairul169/garage-webui:latest container_name: garage-webui restart: unless-stopped volumes: @@ -62,21 +66,21 @@ services: Get the latest binary from the [release page](https://github.com/khairul169/garage-webui/releases/latest) according to your OS architecture. For example: ```sh -$ wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64 -$ chmod +x garage-webui -$ sudo cp garage-webui /usr/local/bin +wget -O garage-webui https://github.com/khairul169/garage-webui/releases/download/1.1.0/garage-webui-v1.1.0-linux-amd64 +chmod +x garage-webui +sudo cp garage-webui /usr/local/bin ``` Run the program with specified `garage.toml` config path. ```sh -$ CONFIG_PATH=./garage.toml garage-webui +CONFIG_PATH=./garage.toml garage-webui ``` If you want to run the program at startup, you may want to create a systemd service. ```sh -$ sudo nano /etc/systemd/system/garage-webui.service +sudo nano /etc/systemd/system/garage-webui.service ``` ``` @@ -97,27 +101,26 @@ WantedBy=default.target Then reload and start the garage-webui service. ```sh -$ sudo systemctl daemon-reload -$ sudo systemctl enable --now garage-webui +sudo systemctl daemon-reload +sudo systemctl enable --now garage-webui ``` ### Configuration To simplify installation, the Garage Web UI uses values from the Garage configuration, such as `rpc_public_addr`, `admin.admin_token`, `s3_web.root_domain`, etc. -Example content of `config.toml`: +Example content of `garage.toml` for Garage v2: ```toml metadata_dir = "/var/lib/garage/meta" data_dir = "/var/lib/garage/data" db_engine = "sqlite" -metadata_auto_snapshot_interval = "6h" replication_factor = 3 compression_level = 2 rpc_bind_addr = "[::]:3901" -rpc_public_addr = "localhost:3901" # Required +rpc_public_addr = "localhost:3901" # Required for Web UI rpc_secret = "YOUR_RPC_SECRET_HERE" [s3_api] @@ -130,7 +133,7 @@ bind_addr = "[::]:3902" root_domain = ".web.domain.com" index = "index.html" -[admin] # Required +[admin] # Required for Web UI api_bind_addr = "[::]:3903" admin_token = "YOUR_ADMIN_TOKEN_HERE" metrics_token = "YOUR_METRICS_TOKEN_HERE" @@ -181,29 +184,38 @@ This project is bootstrapped using TypeScript & React for the UI, and Go for bac ### Setup ```sh -$ git clone https://github.com/khairul169/garage-webui.git -$ cd garage-webui && pnpm install -$ cd backend && pnpm install && cd .. +git clone https://github.com/khairul169/garage-webui.git +cd garage-webui && pnpm install +cd backend && pnpm install && cd .. ``` -### Running +### Development with Docker -Start both the client and server concurrently: +For development with Docker, a `docker-compose.dev.yml` file is provided with 4 Garage v2 instances: ```sh -$ pnpm run dev # or npm run dev +# Create necessary directories for Garage data +mkdir -p dev.local/data-garage/meta dev.local/data-garage/data +mkdir -p dev.local/data-garage2/meta dev.local/data-garage2/data +mkdir -p dev.local/data-garage3/meta dev.local/data-garage3/data +mkdir -p dev.local/data-garage4/meta dev.local/data-garage4/data + +# Generate a secure RPC secret using OpenSSL +# The rpc_secret is used to secure communication between Garage nodes +RPC_SECRET=$(openssl rand -hex 32) +echo "Generated RPC secret: $RPC_SECRET" + +# Copy the template configuration files and replace CONTAINER_NAME with the actual container name +# Using sed with empty string after -i for macOS compatibility +cp garage.toml.template dev.local/garage.toml && sed -i '' "s/CONTAINER_NAME/garage/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage.toml +cp garage.toml.template dev.local/garage2.toml && sed -i '' "s/CONTAINER_NAME/garage2/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage2.toml +cp garage.toml.template dev.local/garage3.toml && sed -i '' "s/CONTAINER_NAME/garage3/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage3.toml +cp garage.toml.template dev.local/garage4.toml && sed -i '' "s/CONTAINER_NAME/garage4/g; s/dev-garage-secret/$RPC_SECRET/g" dev.local/garage4.toml + +# Setup environment variables +cp .env.example .env +cp backend/.env.example backend/.env + +# Start the Garage containers +docker-compose -f docker-compose.dev.yml up -d ``` - -Or start each instance separately: - -```sh -$ pnpm run dev:client -$ cd backend -$ pnpm run dev:server -``` - -## Troubleshooting - -Make sure you are using the latest version of Garage. If the data cannot be loaded, please check whether your instance of Garage has the admin API enabled and the ports are accessible. - -If you encounter any problems, please do not hesitate to submit an issue [here](https://github.com/khairul169/garage-webui/issues). You can describe the problem and attach the error logs. diff --git a/backend/.env.example b/backend/.env.example index f5fa4af..881b174 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,9 @@ # BASE_PATH="" -AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm' -API_BASE_URL="http://garage:3903" -S3_ENDPOINT_URL="http://garage:3900" -API_ADMIN_KEY="" +AUTH_USER_PASS='admin:$2y$10$2i1DScIpTap7oB6KEYLP7um9/ms6LBf.TBzuqfSWRdRMvWRe35Y0S' +API_BASE_URL="http://localhost:3903" +S3_ENDPOINT_URL="http://localhost:3900" +S3_REGION=garage +API_ADMIN_KEY="dev-admin-token" +PORT=3909 +CONFIG_PATH="../dev.local/garage.toml" diff --git a/backend/go.mod b/backend/go.mod index 0423efc..72397c5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module khairul169/garage-webui +module Adekabang/garage-webui go 1.23.0 diff --git a/backend/main.go b/backend/main.go index a69ffbe..32f54cd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,10 +1,10 @@ package main import ( + "Adekabang/garage-webui/router" + "Adekabang/garage-webui/ui" + "Adekabang/garage-webui/utils" "fmt" - "khairul169/garage-webui/router" - "khairul169/garage-webui/ui" - "khairul169/garage-webui/utils" "log" "net/http" "os" diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 9c8bbc1..6fed384 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -1,8 +1,8 @@ package middleware import ( + "Adekabang/garage-webui/utils" "errors" - "khairul169/garage-webui/utils" "net/http" ) diff --git a/backend/router/auth.go b/backend/router/auth.go index 9c425ab..6c038d3 100644 --- a/backend/router/auth.go +++ b/backend/router/auth.go @@ -1,9 +1,9 @@ package router import ( + "Adekabang/garage-webui/utils" "encoding/json" "errors" - "khairul169/garage-webui/utils" "net/http" "strings" diff --git a/backend/router/browse.go b/backend/router/browse.go index 5df5acb..29e3896 100644 --- a/backend/router/browse.go +++ b/backend/router/browse.go @@ -1,13 +1,13 @@ package router import ( + "Adekabang/garage-webui/schema" + "Adekabang/garage-webui/utils" "context" "encoding/json" "errors" "fmt" "io" - "khairul169/garage-webui/schema" - "khairul169/garage-webui/utils" "net/http" "strconv" "strings" diff --git a/backend/router/buckets.go b/backend/router/buckets.go index bbd92b2..d2aeacc 100644 --- a/backend/router/buckets.go +++ b/backend/router/buckets.go @@ -1,10 +1,10 @@ package router import ( + "Adekabang/garage-webui/schema" + "Adekabang/garage-webui/utils" "encoding/json" "fmt" - "khairul169/garage-webui/schema" - "khairul169/garage-webui/utils" "net/http" ) diff --git a/backend/router/config.go b/backend/router/config.go index aca0155..384cb92 100644 --- a/backend/router/config.go +++ b/backend/router/config.go @@ -1,7 +1,7 @@ package router import ( - "khairul169/garage-webui/utils" + "Adekabang/garage-webui/utils" "net/http" ) diff --git a/backend/router/proxy.go b/backend/router/proxy.go index 73c6a16..25894f2 100644 --- a/backend/router/proxy.go +++ b/backend/router/proxy.go @@ -1,8 +1,8 @@ package router import ( + "Adekabang/garage-webui/utils" "fmt" - "khairul169/garage-webui/utils" "net/http" "net/http/httputil" "net/url" diff --git a/backend/router/router.go b/backend/router/router.go index 1cf3134..d4c1b5a 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -1,7 +1,7 @@ package router import ( - "khairul169/garage-webui/middleware" + "Adekabang/garage-webui/middleware" "net/http" ) diff --git a/backend/utils/garage.go b/backend/utils/garage.go index bde29ab..ca691a9 100644 --- a/backend/utils/garage.go +++ b/backend/utils/garage.go @@ -1,12 +1,12 @@ package utils import ( + "Adekabang/garage-webui/schema" "bytes" "encoding/json" "errors" "fmt" "io" - "khairul169/garage-webui/schema" "log" "net/http" "os" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..89e9e1b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,57 @@ +services: + garage: + image: dxflrs/garage:v2.0.0 + container_name: garage + hostname: garage + volumes: + - ./dev.local/garage.toml:/etc/garage.toml + - ./dev.local/data-garage/meta:/var/lib/garage/meta + - ./dev.local/data-garage/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900:3900 + - 3901:3901 + - 3902:3902 + - 3903:3903 + garage2: + image: dxflrs/garage:v2.0.0 + container_name: garage2 + hostname: garage2 + volumes: + - ./dev.local/garage2.toml:/etc/garage.toml + - ./dev.local/data-garage2/meta:/var/lib/garage/meta + - ./dev.local/data-garage2/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 + garage3: + image: dxflrs/garage:v2.0.0 + container_name: garage3 + hostname: garage3 + volumes: + - ./dev.local/garage3.toml:/etc/garage.toml + - ./dev.local/data-garage3/meta:/var/lib/garage/meta + - ./dev.local/data-garage3/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 + garage4: + image: dxflrs/garage:v2.0.0 + container_name: garage4 + hostname: garage4 + volumes: + - ./dev.local/garage4.toml:/etc/garage.toml + - ./dev.local/data-garage4/meta:/var/lib/garage/meta + - ./dev.local/data-garage4/data:/var/lib/garage/data + restart: unless-stopped + ports: + - 3900 + - 3901 + - 3902 + - 3903 diff --git a/docker-compose.yml b/docker-compose.yml index 0a233bb..e1e4588 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - 3903:3903 webui: - image: khairul169/garage-webui:latest + image: ghcr.io/adekabang/garage-webui:latest container_name: garage-webui restart: unless-stopped volumes: diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md new file mode 100644 index 0000000..403ccaf --- /dev/null +++ b/docs/STYLE_GUIDE.md @@ -0,0 +1,1210 @@ +# Garage UI Style Guide + +This document outlines the coding standards, naming conventions, and architectural patterns for the Garage UI project to ensure consistency across all development efforts. + +## Table of Contents + +- [Code Style](#code-style) +- [File and Folder Structure](#file-and-folder-structure) +- [Component Architecture](#component-architecture) +- [TypeScript Guidelines](#typescript-guidelines) +- [React Patterns](#react-patterns) +- [State Management](#state-management) +- [API and Data Fetching](#api-and-data-fetching) +- [Form Handling](#form-handling) +- [UI and Styling](#ui-and-styling) +- [Testing Guidelines](#testing-guidelines) +- [Git and Commit Guidelines](#git-and-commit-guidelines) + +## Code Style + +### General Principles + +- **Consistency**: Follow established patterns in the codebase +- **Readability**: Write self-documenting code with clear naming +- **Simplicity**: Prefer simple, straightforward solutions +- **Performance**: Consider React performance best practices + +### Formatting + +- Use **2 spaces** for indentation +- Use **double quotes** for strings in JSX attributes +- Use **single quotes** for all other strings +- Use **semicolons** consistently +- Max line length: **100 characters** +- Use trailing commas in objects and arrays + +```typescript +// ✅ Good +const config = { + apiUrl: 'http://localhost:8080', + timeout: 5000, +}; + +// ❌ Bad +const config = { + apiUrl: "http://localhost:8080", + timeout: 5000 +} +``` + +### ESLint Configuration + +Follow the existing ESLint configuration: +- TypeScript ESLint rules +- React Hooks rules +- React Refresh rules + +## File and Folder Structure + +### Naming Conventions + +- **Files**: Use kebab-case for file names (`user-profile.tsx`, `auth-hooks.ts`) +- **Components**: Use PascalCase for component files (`UserProfile.tsx`, `NavigationBar.tsx`) +- **Folders**: Use kebab-case for folder names (`user-settings/`, `api-utils/`) +- **Assets**: Use kebab-case (`garage-logo.svg`, `user-avatar.png`) + +### Folder Structure + +``` +src/ +├── app/ # App-level configuration +│ ├── app.tsx # Main App component +│ ├── router.tsx # Route definitions +│ ├── styles.css # Global styles +│ └── themes.ts # Theme configuration +├── assets/ # Static assets +├── components/ # Reusable components +│ ├── containers/ # Container components +│ ├── layouts/ # Layout components +│ └── ui/ # Basic UI components +├── context/ # React contexts +├── hooks/ # Custom hooks +├── lib/ # Utility libraries +├── pages/ # Page components and related logic +│ └── [page-name]/ +│ ├── index.tsx # Main page component +│ ├── components/ # Page-specific components +│ ├── hooks.ts # Page-specific hooks +│ ├── schema.ts # Validation schemas +│ └── stores.ts # Page-specific stores +├── stores/ # Global state stores +└── types/ # TypeScript type definitions +``` + +### File Organization Rules + +1. **Page Structure**: Each page should have its own folder with related components, hooks, schemas, and stores +2. **Component Isolation**: Page-specific components go in the page's `components/` folder +3. **Shared Components**: Reusable components go in `src/components/` +4. **Hooks**: Page-specific hooks in page folder, shared hooks in `src/hooks/` +5. **Types**: Domain-specific types in `src/types/`, component props types inline + +## Component Architecture + +### Component Types + +1. **Page Components**: Top-level route components +2. **Layout Components**: Structural components (headers, sidebars, etc.) +3. **Container Components**: Components that manage state and logic +4. **UI Components**: Presentational components with minimal logic + +### Component Structure + +```typescript +// ✅ Good component structure +import { ComponentPropsWithoutRef, forwardRef } from 'react'; +import { LucideIcon } from 'lucide-react'; +import { Button as BaseButton } from 'react-daisyui'; + +// Types first +type ButtonProps = ComponentPropsWithoutRef & { + icon?: LucideIcon; + href?: string; +}; + +// Component with forwardRef for UI components +const Button = forwardRef( + ({ icon: Icon, children, ...props }, ref) => { + return ( + + {Icon && } + {children} + + ); + } +); + +Button.displayName = 'Button'; + +export default Button; +``` + +### Export Patterns + +- **Default exports** for main components +- **Named exports** for utilities, hooks, and types +- **Barrel exports** for component directories (index.ts files) + +```typescript +// utils.ts +export const formatDate = (date: Date) => { /* ... */ }; +export const formatBytes = (bytes: number) => { /* ... */ }; + +// components/index.ts +export { default as Button } from './button'; +export { default as Input } from './input'; +``` + +## TypeScript Guidelines + +### Type Definitions + +- Use **interfaces** for object shapes that might be extended +- Use **types** for unions, primitives, and computed types +- Use **const assertions** for readonly arrays and objects + +```typescript +// ✅ Good +interface User { + id: string; + name: string; + email: string; +} + +type Theme = 'light' | 'dark' | 'auto'; + +const themes = ['light', 'dark', 'auto'] as const; +type Theme = typeof themes[number]; +``` + +### Generic Patterns + +```typescript +// API response wrapper +type ApiResponse = { + data: T; + success: boolean; + message?: string; +}; + +// Component props with children +type ComponentProps = T & { + children?: React.ReactNode; + className?: string; +}; +``` + +### Type Imports + +Use type-only imports when importing only types: + +```typescript +import type { User } from '@/types/user'; +import type { ComponentProps } from 'react'; +``` + +## React Patterns + +### Hooks Usage + +- **Custom hooks** for reusable logic +- **Built-in hooks** following React best practices +- **Hook naming**: Always start with `use` + +```typescript +// ✅ Good custom hook +export const useAuth = () => { + const { data, isLoading } = useQuery({ + queryKey: ['auth'], + queryFn: () => api.get('/auth/status'), + retry: false, + }); + + return { + isLoading, + isEnabled: data?.enabled, + isAuthenticated: data?.authenticated, + }; +}; +``` + +### Component Patterns + +- Use **functional components** exclusively +- Use **forwardRef** for UI components that need ref access +- Destructure props in function parameters +- Use **early returns** for conditional rendering + +```typescript +// ✅ Good component pattern +const UserCard = ({ user, onEdit, className }: UserCardProps) => { + if (!user) { + return
No user data
; + } + + return ( +
+

{user.name}

+

{user.email}

+ +
+ ); +}; +``` + +## State Management + +### Zustand Stores + +- Use Zustand for **global state** management +- Keep stores **focused** and domain-specific +- Use **immer** for complex state updates + +```typescript +// ✅ Good store pattern +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +interface AppState { + theme: Theme; + sidebarOpen: boolean; + setTheme: (theme: Theme) => void; + toggleSidebar: () => void; +} + +export const useAppStore = create()( + immer((set) => ({ + theme: 'light', + sidebarOpen: false, + setTheme: (theme) => set((state) => { + state.theme = theme; + }), + toggleSidebar: () => set((state) => { + state.sidebarOpen = !state.sidebarOpen; + }), + })) +); +``` + +### Local State + +- Use **useState** for simple local state +- Use **useReducer** for complex state logic +- Use **React Query** state for server state + +## API and Data Fetching + +### React Query Patterns + +- Use **React Query** for all server state +- Follow consistent **query key** patterns +- Use **custom hooks** for API calls + +```typescript +// ✅ Good API hook pattern +export const useUsers = (filters?: UserFilters) => { + return useQuery({ + queryKey: ['users', filters], + queryFn: () => api.get('/users', { params: filters }), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const useCreateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (userData: CreateUserInput) => + api.post('/users', userData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); +}; +``` + +### API Client + +- Use consistent **error handling** +- Follow **RESTful** conventions +- Use **TypeScript** for request/response types + +```typescript +// lib/api.ts +const api = { + get: (url: string, config?: AxiosRequestConfig) => + axios.get(url, config).then(res => res.data), + + post: (url: string, data?: any, config?: AxiosRequestConfig) => + axios.post(url, data, config).then(res => res.data), + + // ... other methods +}; +``` + +## Form Handling + +### React Hook Form + Zod + +- Use **React Hook Form** for all forms +- Use **Zod** for validation schemas +- Use **@hookform/resolvers** for integration + +```typescript +// ✅ Good form pattern +// schema.ts +export const createUserSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email'), + role: z.enum(['admin', 'user']), +}); + +export type CreateUserInput = z.infer; + +// component.tsx +const CreateUserForm = ({ onSubmit }: CreateUserFormProps) => { + const form = useForm({ + resolver: zodResolver(createUserSchema), + defaultValues: { + name: '', + email: '', + role: 'user', + }, + }); + + return ( +
+ + +
+ ); +}; +``` + +## UI and Styling + +### TailwindCSS + DaisyUI + +- Use **TailwindCSS** utility classes +- Use **DaisyUI** components as base +- Use **clsx** or **tailwind-merge** for conditional classes + +```typescript +import { cn } from '@/lib/utils'; // tailwind-merge utility + +const Button = ({ variant, size, className, ...props }: ButtonProps) => { + return ( + + ) : null} + + ); + } return ( - {children} {onRemove ? ( @@ -33,7 +58,7 @@ const Chips = forwardRef( ) : null} - + ); } ); diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 7df41da..00d25ab 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -8,7 +8,7 @@ type Props = ComponentPropsWithoutRef & { onCreateOption?: (inputValue: string) => void; }; -const Select = forwardRef(({ creatable, ...props }, ref) => { +const Select = forwardRef, Props>(({ creatable, ...props }, ref) => { const Comp = creatable ? Creatable : BaseSelect; return ( diff --git a/src/context/page-context.tsx b/src/context/page-context.tsx index 444518f..b8bce45 100644 --- a/src/context/page-context.tsx +++ b/src/context/page-context.tsx @@ -5,6 +5,8 @@ import React, { useCallback, useContext, useEffect, + useMemo, + useRef, useState, } from "react"; @@ -16,8 +18,8 @@ type PageContextValues = { export const PageContext = createContext< | (PageContextValues & { - setValue: (values: Partial) => void; - }) + setValue: (values: Partial) => void; + }) | null >(null); @@ -34,8 +36,13 @@ export const PageContextProvider = ({ children }: PropsWithChildren) => { setValues((prev) => ({ ...prev, ...value })); }, []); + const contextValue = useMemo(() => ({ + ...values, + setValue + }), [values, setValue]); + return ( - + ); }; @@ -47,13 +54,18 @@ const Page = memo((props: PageProps) => { throw new Error("Page component must be used within a PageContextProvider"); } - useEffect(() => { - context.setValue(props); + const setValueRef = useRef(context.setValue); + setValueRef.current = context.setValue; + useEffect(() => { + setValueRef.current(props); + }, [props]); + + useEffect(() => { return () => { - context.setValue(initialValues); + setValueRef.current(initialValues); }; - }, [props, context.setValue]); + }, []); return null; }); diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index d302fcb..8f41fbc 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,20 +1,20 @@ import { useCallback, useRef } from "react"; -export const useDebounce = void>( - fn: T, +export const useDebounce = ( + fn: (...args: Args) => void, delay: number = 500 ) => { const timerRef = useRef(null); const debouncedFn = useCallback( - (...args: any[]) => { + (...args: Args) => { if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current = setTimeout(() => fn(...args), delay); }, - [fn] + [fn, delay] ); - return debouncedFn as T; + return debouncedFn; }; diff --git a/src/hooks/useDisclosure.ts b/src/hooks/useDisclosure.ts index 453fe91..0b73a30 100644 --- a/src/hooks/useDisclosure.ts +++ b/src/hooks/useDisclosure.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -export const useDisclosure = () => { +export const useDisclosure = () => { const dialogRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [data, setData] = useState(null); diff --git a/src/lib/api.ts b/src/lib/api.ts index db03c9a..6cc7fde 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,9 +2,9 @@ import * as utils from "@/lib/utils"; import { BASE_PATH } from "./consts"; type FetchOptions = Omit & { - params?: Record; + params?: Record; headers?: Record; - body?: any; + body?: BodyInit | Record | unknown[] | null; }; export const API_URL = BASE_PATH + "/api"; @@ -20,7 +20,7 @@ export class APIError extends Error { } const api = { - async fetch(url: string, options?: Partial) { + async fetch(url: string, options?: Partial) { const headers: Record = {}; const _url = new URL(API_URL + url, window.location.origin); @@ -30,16 +30,27 @@ const api = { }); } - if ( - typeof options?.body === "object" && - !(options.body instanceof FormData) - ) { - options.body = JSON.stringify(options.body); - headers["Content-Type"] = "application/json"; + let body: BodyInit | null | undefined = undefined; + if (options?.body) { + if ( + (typeof options.body === "object" && !Array.isArray(options.body) && + !(options.body instanceof FormData) && + !(options.body instanceof URLSearchParams) && + !(options.body instanceof ReadableStream) && + !(options.body instanceof ArrayBuffer) && + !(options.body instanceof Blob)) || + Array.isArray(options.body) + ) { + body = JSON.stringify(options.body); + headers["Content-Type"] = "application/json"; + } else { + body = options.body as BodyInit; + } } const res = await fetch(_url, { ...options, + body, credentials: "include", headers: { ...headers, ...(options?.headers || {}) }, }); @@ -66,28 +77,28 @@ const api = { return data as unknown as T; }, - async get(url: string, options?: Partial) { + async get(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "GET", }); }, - async post(url: string, options?: Partial) { + async post(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "POST", }); }, - async put(url: string, options?: Partial) { + async put(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "PUT", }); }, - async delete(url: string, options?: Partial) { + async delete(url: string, options?: Partial) { return this.fetch(url, { ...options, method: "DELETE", diff --git a/src/lib/disclosure.ts b/src/lib/disclosure.ts index 5bf90ce..9157da3 100644 --- a/src/lib/disclosure.ts +++ b/src/lib/disclosure.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { createStore, useStore } from "zustand"; -export const createDisclosure = () => { +export const createDisclosure = () => { const store = createStore(() => ({ data: undefined as T | null, isOpen: false, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5ebd62c..2fa830a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -8,7 +8,7 @@ import { BASE_PATH } from "./consts"; dayjs.extend(dayjsRelativeTime); export { dayjs }; -export const cn = (...args: any[]) => { +export const cn = (...args: Parameters) => { return twMerge(clsx(...args)); }; diff --git a/src/pages/buckets/components/bucket-card.tsx b/src/pages/buckets/components/bucket-card.tsx index 6f5eee4..c27a2e6 100644 --- a/src/pages/buckets/components/bucket-card.tsx +++ b/src/pages/buckets/components/bucket-card.tsx @@ -2,12 +2,19 @@ import { Bucket } from "../types"; import { ArchiveIcon, ChartPie, ChartScatter } from "lucide-react"; import { readableBytes } from "@/lib/utils"; import Button from "@/components/ui/button"; +import { useBucketDetails } from "../hooks"; type Props = { data: Bucket & { aliases: string[] }; }; const BucketCard = ({ data }: Props) => { + const { data: bucketDetails } = useBucketDetails(data.id); + + // Use detailed data if available, otherwise fall back to basic data + const bytes = bucketDetails?.bytes ?? data.bytes; + const objects = bucketDetails?.objects ?? data.objects; + return (
@@ -25,7 +32,7 @@ const BucketCard = ({ data }: Props) => { Usage

- {readableBytes(data.bytes)} + {bytes != null ? readableBytes(bytes) : "n/a"}

@@ -34,7 +41,9 @@ const BucketCard = ({ data }: Props) => { Objects

-

{data.objects}

+

+ {objects != null ? objects.toLocaleString() : "n/a"} +

diff --git a/src/pages/buckets/components/create-bucket-dialog.tsx b/src/pages/buckets/components/create-bucket-dialog.tsx index 82e1e07..6851309 100644 --- a/src/pages/buckets/components/create-bucket-dialog.tsx +++ b/src/pages/buckets/components/create-bucket-dialog.tsx @@ -22,7 +22,7 @@ const CreateBucketDialog = () => { useEffect(() => { if (isOpen) form.setFocus("globalAlias"); - }, [isOpen]); + }, [isOpen, form]); const createBucket = useCreateBucket({ onSuccess: () => { diff --git a/src/pages/buckets/hooks.ts b/src/pages/buckets/hooks.ts index 70b4f11..587201e 100644 --- a/src/pages/buckets/hooks.ts +++ b/src/pages/buckets/hooks.ts @@ -3,19 +3,40 @@ import { useMutation, UseMutationOptions, useQuery, + useQueries, } from "@tanstack/react-query"; -import { GetBucketRes } from "./types"; +import { GetBucketRes, Bucket } from "./types"; import { CreateBucketSchema } from "./schema"; export const useBuckets = () => { return useQuery({ queryKey: ["buckets"], - queryFn: () => api.get("/buckets"), + queryFn: () => api.get("/v2/ListBuckets"), + }); +}; + +export const useBucketDetails = (id?: string | null) => { + return useQuery({ + queryKey: ["bucket-details", id], + queryFn: () => api.get("/v2/GetBucketInfo", { params: { id } }), + enabled: !!id, + }); +}; + +export const useBucketsWithDetails = () => { + const { data: buckets } = useBuckets(); + + return useQueries({ + queries: (buckets || []).map((bucket) => ({ + queryKey: ["bucket-details", bucket.id], + queryFn: () => api.get("/v2/GetBucketInfo", { params: { id: bucket.id } }), + enabled: !!bucket.id, + })), }); }; export const useCreateBucket = ( - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (body) => api.post("/v2/CreateBucket", { body }), diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index fea7b84..5abcf79 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -86,7 +86,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => { useEffect(() => { if (isOpen) form.setFocus("name"); - }, [isOpen]); + }, [isOpen, form]); const createFolder = usePutObject(bucketName, { onSuccess: () => { diff --git a/src/pages/buckets/manage/browse/browse-tab.tsx b/src/pages/buckets/manage/browse/browse-tab.tsx index 45e78d7..6ec4f94 100644 --- a/src/pages/buckets/manage/browse/browse-tab.tsx +++ b/src/pages/buckets/manage/browse/browse-tab.tsx @@ -30,7 +30,7 @@ const BrowseTab = () => { const newParams = new URLSearchParams(searchParams); newParams.set("prefix", prefix); setSearchParams(newParams); - }, [curPrefix]); + }, [curPrefix, prefixHistory, searchParams, setSearchParams]); const gotoPrefix = (prefix: string) => { const history = prefixHistory.slice(0, curPrefix + 1); diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts index 203c049..4ddebc0 100644 --- a/src/pages/buckets/manage/browse/hooks.ts +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -23,7 +23,7 @@ export const useBrowseObjects = ( export const usePutObject = ( bucket: string, - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: async (body) => { @@ -40,7 +40,7 @@ export const usePutObject = ( export const useDeleteObject = ( bucket: string, - options?: UseMutationOptions + options?: UseMutationOptions ) => { return useMutation({ mutationFn: (data) => diff --git a/src/pages/buckets/manage/browse/object-actions.tsx b/src/pages/buckets/manage/browse/object-actions.tsx index 97ed549..dd91423 100644 --- a/src/pages/buckets/manage/browse/object-actions.tsx +++ b/src/pages/buckets/manage/browse/object-actions.tsx @@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { handleError } from "@/lib/utils"; import { API_URL } from "@/lib/api"; -import { shareDialog } from "./share-dialog"; +import { shareDialog } from "./share-dialog-store"; type Props = { prefix?: string; @@ -36,8 +36,7 @@ const ObjectActions = ({ prefix = "", object, end }: Props) => { const onDelete = () => { if ( window.confirm( - `Are you sure you want to delete this ${ - isDirectory ? "directory and its content" : "object" + `Are you sure you want to delete this ${isDirectory ? "directory and its content" : "object" }?` ) ) { diff --git a/src/pages/buckets/manage/browse/share-dialog-store.ts b/src/pages/buckets/manage/browse/share-dialog-store.ts new file mode 100644 index 0000000..0cc0a88 --- /dev/null +++ b/src/pages/buckets/manage/browse/share-dialog-store.ts @@ -0,0 +1,3 @@ +import { createDisclosure } from "@/lib/disclosure"; + +export const shareDialog = createDisclosure<{ key: string; prefix: string }>(); diff --git a/src/pages/buckets/manage/browse/share-dialog.tsx b/src/pages/buckets/manage/browse/share-dialog.tsx index fc98e98..1bb6d7c 100644 --- a/src/pages/buckets/manage/browse/share-dialog.tsx +++ b/src/pages/buckets/manage/browse/share-dialog.tsx @@ -1,4 +1,3 @@ -import { createDisclosure } from "@/lib/disclosure"; import { Alert, Modal } from "react-daisyui"; import { useBucketContext } from "../context"; import { useConfig } from "@/hooks/useConfig"; @@ -8,8 +7,7 @@ import Button from "@/components/ui/button"; import { Copy, FileWarningIcon } from "lucide-react"; import { copyToClipboard } from "@/lib/utils"; import Checkbox from "@/components/ui/checkbox"; - -export const shareDialog = createDisclosure<{ key: string; prefix: string }>(); +import { shareDialog } from "./share-dialog-store"; const ShareDialog = () => { const { isOpen, data, dialogRef } = shareDialog.use(); @@ -26,12 +24,12 @@ const ShareDialog = () => { bucketName + rootDomain, bucketName + rootDomain + `:${websitePort}`, ], - [bucketName, config?.s3_web] + [bucketName, rootDomain, websitePort] ); useEffect(() => { setDomain(bucketName); - }, [domains]); + }, [bucketName]); const url = "http://" + domain + "/" + data?.prefix + data?.key; @@ -60,6 +58,7 @@ const ShareDialog = () => { value={url} className="w-full pr-12" onFocus={(e) => e.target.select()} + readOnly />