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
+10 -6
View File
@@ -2,13 +2,15 @@ import { Link, NavLink, Outlet } from "react-router-dom";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/projects", label: "Projects" },
{ to: "/ssh-keys", label: "SSH Keys" },
{ to: "/tool-types", label: "Tool Types" },
{ to: "/settings", label: "Settings" }
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Dashboard", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
{ to: "/tool-types", label: "Tool Types", icon: "code" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
export const AppShell = () => {
@@ -32,6 +34,7 @@ export const AppShell = () => {
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
@@ -46,6 +49,7 @@ export const AppShell = () => {
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
</NavLink>
))}
+14 -1
View File
@@ -1,5 +1,7 @@
import React, { useState } from "react";
import { Icon } from "./icon";
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
@@ -129,6 +131,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
onClick={onCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
@@ -137,7 +140,17 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
disabled={loading || !hasChanges || !message.trim()}
type="button"
>
{loading ? "Committing..." : "Commit Changes"}
{loading ? (
<>
<Icon name="loading" size="sm" />
Committing...
</>
) : (
<>
<Icon name="commit" size="sm" />
Commit Changes
</>
)}
</button>
</div>
</div>
+14 -1
View File
@@ -4,6 +4,7 @@ import { apiClient } from "../api/client";
import { useAuth } from "../state/auth";
import { CodeEditor } from "../components/code-editor";
import { CommitDialog } from "../components/commit-dialog";
import { Icon } from "../components/icon";
import { SyntaxHighlighter } from "../components/syntax-highlighter";
import { detectLanguage } from "../utils/language";
@@ -172,6 +173,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
@@ -183,13 +185,24 @@ export const FileEditor: React.FC<FileEditorProps> = ({
disabled={content === originalContent || saving}
type="button"
>
{saving ? "Saving..." : "Save"}
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
+20 -13
View File
@@ -9,6 +9,7 @@ import {
pushRepository,
type GitStatus,
} from "../api/git_repositories";
import { Icon } from "./icon";
import { MergeDialog } from "./merge-dialog";
interface GitToolbarProps {
@@ -140,7 +141,13 @@ export const GitToolbar = ({
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? `${b}` : b}
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
@@ -150,18 +157,18 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
+ New
<Icon name="add" size="sm" /> New
</button>
</div>
<div className="toolbar-group">
<button
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading}
type="button"
>
Fetch
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className="toolbar-button"
@@ -169,7 +176,7 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
Pull
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
@@ -178,7 +185,7 @@ export const GitToolbar = ({
disabled={loading || !status?.ahead}
type="button"
>
Push
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</button>
<button
@@ -187,7 +194,7 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
🔀 Merge
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
@@ -217,24 +224,24 @@ export const GitToolbar = ({
disabled={loading || !newBranchName.trim()}
type="button"
>
Create
<Icon name="add" size="sm" /> Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
Cancel
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted">🗑 {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"> {status.untracked.length} untracked</span>}
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
</div>
)}
+150
View File
@@ -0,0 +1,150 @@
import React from "react";
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
};
export interface IconProps {
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
}
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
sm: 16,
md: 20,
lg: 24,
xl: 32,
};
export const Icon: React.FC<IconProps> = ({
name,
size = "md",
color,
weight = "regular",
className,
ariaLabel,
}) => {
const IconComponent = iconMap[name];
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
return (
<span
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
style={{ color }}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role="img"
>
<IconComponent size={sizeValue} weight={weight} />
</span>
);
};
+13 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { mergeBranches } from "../api/git_repositories";
import { Icon } from "./icon";
interface MergeDialogProps {
projectId: string;
@@ -114,6 +115,7 @@ export const MergeDialog = ({
disabled={loading}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
@@ -122,7 +124,17 @@ export const MergeDialog = ({
disabled={loading || !sourceBranch}
type="button"
>
{loading ? "Merging..." : "Merge"}
{loading ? (
<>
<Icon name="loading" size="sm" />
Merging...
</>
) : (
<>
<Icon name="merge" size="sm" />
Merge
</>
)}
</button>
</div>
</div>
+13 -1
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { Icon } from "./icon";
import { highlightCode, loadLanguage } from "../utils/language";
interface SyntaxHighlighterProps {
@@ -40,7 +42,17 @@ export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
onClick={handleCopy}
type="button"
>
{copied ? "Copied!" : "Copy"}
{copied ? (
<>
<Icon name="success" size="sm" />
Copied!
</>
) : (
<>
<Icon name="copy" size="sm" />
Copy
</>
)}
</button>
</div>
<div className="code-container">
+6 -3
View File
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
import { Icon } from "./icon";
interface WorkspaceHeaderProps {
project: {
@@ -16,7 +17,9 @@ export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps)
return (
<div className="workspace-header">
<div className="workspace-header-left">
<div className="workspace-header-icon">📁</div>
<div className="workspace-header-icon">
<Icon name="folder" size="lg" />
</div>
<div className="workspace-header-info">
<h1 className="workspace-header-title">{project.name}</h1>
{currentRepo && (
@@ -29,14 +32,14 @@ export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps)
className="workspace-header-action-btn"
to={`/projects/${project.id}/repositories/${currentRepo?.id || ""}/history`}
>
<span className="icon">🕐</span>
<Icon name="history" size="sm" />
History
</Link>
<Link
className="workspace-header-action-btn"
to={`/projects/${project.id}/settings`}
>
<span className="icon"></span>
<Icon name="settings" size="sm" />
Settings
</Link>
</div>