feat: implement universal icon system with Phosphor Icons

- Install @phosphor-icons/react package
- Create centralized Icon component with size/weight/color variants
- Create icon registry with 34 icons across 5 categories
- Replace all raw Unicode symbols with proper icon components
- Add icons to navigation, buttons, status indicators, git operations
- Add icon CSS with consistent sizing and spacing
- Fix type definitions for Phosphor icon compatibility

Quality gates: typecheck ✓, lint ✓, build ✓ (375KB bundle)
This commit is contained in:
Fusion
2026-05-19 19:33:06 +02:00
parent cccc4a9d5a
commit 6f41fa7cbe
37 changed files with 1037 additions and 41 deletions
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { Icon } from "../components/icon";
const CARDS = [
{ label: "Projects", key: "projects" },
@@ -50,6 +51,7 @@ export const DashboardPage = () => {
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -68,9 +70,11 @@ export const DashboardPage = () => {
<div className="quick-actions">
<button className="primary-button" type="button">
<Icon name="add" size="sm" />
New Project
</button>
<button className="secondary-button" type="button">
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
+3 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
import { Icon } from "../components/icon";
export const GitHistoryPage = () => {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
@@ -70,6 +71,7 @@ export const GitHistoryPage = () => {
<section className="stack">
<p>Failed to load commit history</p>
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</section>
@@ -155,7 +157,7 @@ export const GitHistoryPage = () => {
<div className="detail-header">
<h3>Commit Details</h3>
<button className="ghost-button" onClick={handleCloseDetail} type="button">
×
<Icon name="close" size="sm" />
</button>
</div>
+10 -3
View File
@@ -10,6 +10,7 @@ import {
type URLParseResult,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { Icon } from "../components/icon";
type RepoStatus = "loading" | "ready" | "error";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
@@ -160,6 +161,7 @@ export const GitRepositoriesPage = () => {
<div className="page-header">
<h1>Repositories</h1>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
<Icon name="add" size="sm" />
New Repository
</button>
</div>
@@ -170,6 +172,7 @@ export const GitRepositoriesPage = () => {
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -256,12 +259,14 @@ export const GitRepositoriesPage = () => {
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid"> Valid git URL</span>
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
This looks like a browser URL
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">
@@ -279,7 +284,7 @@ export const GitRepositoriesPage = () => {
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
Invalid URL
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
@@ -292,9 +297,11 @@ export const GitRepositoriesPage = () => {
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
Create
</button>
</div>
+3
View File
@@ -18,6 +18,8 @@ export const NotFoundPage = () => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
import { Icon } from "../components/icon";
export const LoginRedirectPage = () => {
const nextPath = new URLSearchParams(window.location.search).get("next") ?? "/";
const encodedNext = encodeURIComponent(nextPath);
@@ -27,6 +29,7 @@ export const LoginRedirectPage = () => {
<h1>Sign in required</h1>
<p className="muted">You need to authenticate to access this section.</p>
<a className="primary-button" href={`${API_BASE_URL}/auth/login?next=${encodedNext}`}>
<Icon name="profile" size="sm" />
Continue to login
</a>
</section>
+24 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { Icon } from "../components/icon";
import { useAuth } from "../state/auth";
import type { UserProfile } from "../api/profile";
@@ -99,6 +100,7 @@ export const ProfilePage = () => {
<div className="card stack">
<p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -120,7 +122,17 @@ export const ProfilePage = () => {
onClick={() => fileInputRef.current?.click()}
type="button"
>
{status === "saving" ? "Uploading..." : "Change Avatar"}
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Uploading...
</>
) : (
<>
<Icon name="edit" size="sm" />
Change Avatar
</>
)}
</button>
<input
accept="image/png,image/jpeg"
@@ -162,7 +174,17 @@ export const ProfilePage = () => {
onClick={() => void handleSave()}
type="button"
>
{status === "saving" ? "Saving..." : "Save Changes"}
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Changes
</>
)}
</button>
</div>
</div>
+19 -1
View File
@@ -10,6 +10,7 @@ import {
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { Icon } from "../components/icon";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
@@ -110,6 +111,7 @@ export const ProjectsPage = () => {
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
<Icon name="add" size="sm" />
New Project
</button>
</div>
@@ -120,6 +122,7 @@ export const ProjectsPage = () => {
<div className="card stack">
<p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -144,6 +147,7 @@ export const ProjectsPage = () => {
onClick={() => openEdit(project)}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirmId === project.id ? (
@@ -154,6 +158,7 @@ export const ProjectsPage = () => {
onClick={() => void handleDelete(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
@@ -161,6 +166,7 @@ export const ProjectsPage = () => {
onClick={() => setDeleteConfirmId(null)}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
@@ -170,6 +176,7 @@ export const ProjectsPage = () => {
onClick={() => setDeleteConfirmId(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
@@ -205,10 +212,21 @@ export const ProjectsPage = () => {
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? "Create" : "Save"}
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import { Link, useParams, useSearchParams } from "react-router-dom";
@@ -155,6 +156,7 @@ export const RepoWorkspace = () => {
onClick={() => void loadRepositories()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -338,7 +340,7 @@ const FileBrowser = ({
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
📁 ..
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.map((entry) => {
@@ -350,7 +352,7 @@ const FileBrowser = ({
onClick={() => handleEntryClick(entry)}
type="button"
>
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
+13 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
@@ -66,6 +67,7 @@ export const SettingsPage = () => {
<section className="stack">
<p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</section>
@@ -132,7 +134,17 @@ export const SettingsPage = () => {
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? "Saving..." : "Save Settings"}
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Settings
</>
)}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
+14 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
export const SSHKeysPage = () => {
const [keys, setKeys] = useState<SSHKey[]>([]);
@@ -77,7 +78,17 @@ export const SSHKeysPage = () => {
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? "Generating..." : "Generate SSH Key"}
{generating ? (
<>
<Icon name="loading" size="sm" />
Generating...
</>
) : (
<>
<Icon name="add" size="sm" />
Generate SSH Key
</>
)}
</button>
</form>
@@ -93,6 +104,7 @@ export const SSHKeysPage = () => {
onClick={() => handleDelete(key.id)}
className="danger-button"
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
@@ -107,6 +119,7 @@ export const SSHKeysPage = () => {
onClick={() => copyToClipboard(key.public_key)}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Full Key
</button>
</div>
+30 -4
View File
@@ -8,6 +8,7 @@ import {
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import { Icon } from "../components/icon";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
@@ -134,7 +135,10 @@ export const ToolTypesPage = () => {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>Retry</button>
<button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
);
}
@@ -143,7 +147,10 @@ export const ToolTypesPage = () => {
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h1>Tool Types</h1>
<button onClick={openCreate}>Create Tool Type</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
{toolTypes.length === 0 ? (
@@ -161,12 +168,14 @@ export const ToolTypesPage = () => {
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
@@ -179,9 +188,13 @@ export const ToolTypesPage = () => {
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>Cancel</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
@@ -250,8 +263,21 @@ export const ToolTypesPage = () => {
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">{dialogMode === "create" ? "Create" : "Update"}</button>
<button type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Update
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>