Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,630 changes: 1,592 additions & 38 deletions luxview-dashboard/package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions luxview-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@
},
"dependencies": {
"axios": "^1.7.9",
"highlight.js": "^11.11.1",
"i18next": "^25.8.14",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
"react-joyride": "^2.9.3",
"react-markdown": "^9.1.0",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.2"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions luxview-dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { PullRequests } from './pages/PullRequests';
import { NewPullRequest } from './pages/NewPullRequest';
import { PullRequestDetail } from './pages/PullRequestDetail';
import { RepositoryCode } from './pages/RepositoryCode';
import { RepositorySettings } from './pages/RepositorySettings';
import { Issues } from './pages/Issues';
import { NewIssue } from './pages/NewIssue';
import { IssueDetail } from './pages/IssueDetail';
import { RepositoryCommits } from './pages/RepositoryCommits';
import { CommitDetail } from './pages/CommitDetail';
import { RepositoryBranches } from './pages/RepositoryBranches';
Expand Down Expand Up @@ -77,6 +81,10 @@ export function App() {
<Route path="repositories/:repoId/pulls" element={<PullRequests />} />
<Route path="repositories/:repoId/pulls/new" element={<NewPullRequest />} />
<Route path="repositories/:repoId/pulls/:number" element={<PullRequestDetail />} />
<Route path="repositories/:repoId/issues" element={<Issues />} />
<Route path="repositories/:repoId/issues/new" element={<NewIssue />} />
<Route path="repositories/:repoId/issues/:number" element={<IssueDetail />} />
<Route path="repositories/:repoId/settings" element={<RepositorySettings />} />
<Route path="resources" element={<Resources />} />
<Route path="resources/db/:serviceId" element={<DbExplorer />} />
<Route path="resources/storage/:serviceId" element={<StorageExplorer />} />
Expand Down
94 changes: 94 additions & 0 deletions luxview-dashboard/src/api/issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { api } from './client';

export type IssueStatus = 'open' | 'closed';

export interface Label {
id: string;
repositoryId: string;
name: string;
color: string;
description: string;
createdAt: string;
}

export interface Issue {
id: string;
repositoryId: string;
authorId: string;
number: number;
title: string;
body: string;
status: IssueStatus;
createdAt: string;
updatedAt: string;
closedAt?: string;
author?: { id: string; username: string; avatarUrl: string };
labels?: Label[];
}

export interface IssueComment {
id: string;
issueId: string;
authorId: string;
body: string;
createdAt: string;
updatedAt: string;
author?: { id: string; username: string; avatarUrl: string };
}

export const issuesApi = {
async list(repositoryId: string, status = '', limit = 30, offset = 0): Promise<{ issues: Issue[]; total: number }> {
const { data } = await api.get<{ issues: Issue[]; total: number }>(`/repositories/${repositoryId}/issues`, {
params: { status, limit, offset },
});
return { issues: data.issues ?? [], total: data.total ?? 0 };
},

async get(repositoryId: string, number: number): Promise<Issue> {
const { data } = await api.get<Issue>(`/repositories/${repositoryId}/issues/${number}`);
return data;
},

async create(repositoryId: string, payload: { title: string; body?: string; labelIds?: string[] }): Promise<Issue> {
const { data } = await api.post<Issue>(`/repositories/${repositoryId}/issues`, payload);
return data;
},

async update(repositoryId: string, number: number, payload: { title?: string; body?: string; labelIds?: string[] }): Promise<Issue> {
const { data } = await api.patch<Issue>(`/repositories/${repositoryId}/issues/${number}`, payload);
return data;
},

async setStatus(repositoryId: string, number: number, status: IssueStatus): Promise<Issue> {
const { data } = await api.post<Issue>(`/repositories/${repositoryId}/issues/${number}/status`, { status });
return data;
},

async listComments(repositoryId: string, number: number): Promise<IssueComment[]> {
const { data } = await api.get<{ comments: IssueComment[] }>(`/repositories/${repositoryId}/issues/${number}/comments`);
return data.comments ?? [];
},

async addComment(repositoryId: string, number: number, body: string): Promise<IssueComment> {
const { data } = await api.post<IssueComment>(`/repositories/${repositoryId}/issues/${number}/comments`, { body });
return data;
},

async deleteComment(repositoryId: string, number: number, commentId: string): Promise<void> {
await api.delete(`/repositories/${repositoryId}/issues/${number}/comments/${commentId}`);
},

async listLabels(repositoryId: string): Promise<Label[]> {
const { data } = await api.get<{ labels: Label[] }>(`/repositories/${repositoryId}/labels`);
return data.labels ?? [];
},

async createLabel(repositoryId: string, payload: { name: string; color?: string; description?: string }): Promise<Label> {
const { data } = await api.post<Label>(`/repositories/${repositoryId}/labels`, payload);
return data;
},

async deleteLabel(repositoryId: string, labelId: string): Promise<void> {
await api.delete(`/repositories/${repositoryId}/labels/${labelId}`);
},
};
116 changes: 114 additions & 2 deletions luxview-dashboard/src/api/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface LuxViewRepository {
userId: string;
name: string;
slug: string;
description: string;
defaultBranch: string;
visibility: RepositoryVisibility;
ownerUsername: string;
Expand All @@ -30,6 +31,7 @@ export interface RepositoryRemote {
export interface CreateRepositoryPayload {
name: string;
slug?: string;
description?: string;
defaultBranch?: string;
visibility?: RepositoryVisibility;
}
Expand All @@ -42,11 +44,21 @@ export const repositoriesApi = {
return data.repositories ?? [];
},

async get(repositoryId: string): Promise<LuxViewRepository> {
const { data } = await api.get<LuxViewRepository>(`/repositories/${repositoryId}`);
return data;
},

async create(payload: CreateRepositoryPayload): Promise<LuxViewRepository> {
const { data } = await api.post<LuxViewRepository>('/repositories', payload);
return data;
},

async update(repositoryId: string, payload: { name?: string; description?: string }): Promise<LuxViewRepository> {
const { data } = await api.patch<LuxViewRepository>(`/repositories/${repositoryId}`, payload);
return data;
},

async listBranches(repositoryId: string): Promise<string[]> {
const { data } = await api.get<string[]>(`/repositories/${repositoryId}/branches`);
return Array.isArray(data) ? data : [];
Expand Down Expand Up @@ -157,8 +169,8 @@ export const pullRequestsApi = {
return data.files ?? [];
},

async merge(repositoryId: string, number: number): Promise<PullRequest> {
const { data } = await api.post<PullRequest>(`/repositories/${repositoryId}/pulls/${number}/merge`);
async merge(repositoryId: string, number: number, strategy: MergeStrategy = 'merge'): Promise<PullRequest> {
const { data } = await api.post<PullRequest>(`/repositories/${repositoryId}/pulls/${number}/merge`, { strategy });
return data;
},

Expand All @@ -180,4 +192,104 @@ export const pullRequestsApi = {
async deleteComment(repositoryId: string, number: number, commentId: string): Promise<void> {
await api.delete(`/repositories/${repositoryId}/pulls/${number}/comments/${commentId}`);
},

async listReviews(repositoryId: string, number: number): Promise<PRReview[]> {
const { data } = await api.get<{ reviews: PRReview[] }>(`/repositories/${repositoryId}/pulls/${number}/reviews`);
return data.reviews ?? [];
},

async addReview(repositoryId: string, number: number, state: ReviewState, body = ''): Promise<PRReview> {
const { data } = await api.post<PRReview>(`/repositories/${repositoryId}/pulls/${number}/reviews`, { state, body });
return data;
},

async listReviewComments(repositoryId: string, number: number): Promise<ReviewComment[]> {
const { data } = await api.get<{ comments: ReviewComment[] }>(`/repositories/${repositoryId}/pulls/${number}/review-comments`);
return data.comments ?? [];
},

async addReviewComment(repositoryId: string, number: number, payload: { path: string; line: number; side?: ReviewSide; body: string }): Promise<ReviewComment> {
const { data } = await api.post<ReviewComment>(`/repositories/${repositoryId}/pulls/${number}/review-comments`, payload);
return data;
},

async resolveReviewComment(repositoryId: string, number: number, commentId: string, resolved: boolean): Promise<void> {
await api.patch(`/repositories/${repositoryId}/pulls/${number}/review-comments/${commentId}`, { resolved });
},

async deleteReviewComment(repositoryId: string, number: number, commentId: string): Promise<void> {
await api.delete(`/repositories/${repositoryId}/pulls/${number}/review-comments/${commentId}`);
},

async checks(repositoryId: string, number: number): Promise<StatusCheck[]> {
const { data } = await api.get<{ checks: StatusCheck[] }>(`/repositories/${repositoryId}/pulls/${number}/checks`);
return data.checks ?? [];
},
};

export type MergeStrategy = 'merge' | 'squash' | 'rebase';
export type ReviewState = 'approved' | 'changes_requested' | 'commented';
export type ReviewSide = 'old' | 'new';

export interface PRReview {
id: string;
pullRequestId: string;
reviewerId: string;
state: ReviewState;
body: string;
commitSha: string;
createdAt: string;
reviewer?: { id: string; username: string; avatarUrl: string };
}

export interface ReviewComment {
id: string;
pullRequestId: string;
authorId: string;
path: string;
line: number;
side: ReviewSide;
body: string;
resolved: boolean;
createdAt: string;
updatedAt: string;
author?: { id: string; username: string; avatarUrl: string };
}

export interface StatusCheck {
name: string;
status: string;
runId: string;
commitSha: string;
createdAt: string;
finishedAt?: string;
}

export interface BranchProtectionRule {
id: string;
repositoryId: string;
branch: string;
requireReviews: boolean;
requiredApprovals: number;
dismissStaleReviews: boolean;
requireStatusChecks: boolean;
blockForcePush: boolean;
createdAt: string;
updatedAt: string;
}

export const branchProtectionApi = {
async list(repositoryId: string): Promise<BranchProtectionRule[]> {
const { data } = await api.get<{ rules: BranchProtectionRule[] }>(`/repositories/${repositoryId}/branch-protection`);
return data.rules ?? [];
},

async upsert(repositoryId: string, rule: Omit<BranchProtectionRule, 'id' | 'repositoryId' | 'createdAt' | 'updatedAt'>): Promise<BranchProtectionRule> {
const { data } = await api.put<BranchProtectionRule>(`/repositories/${repositoryId}/branch-protection`, rule);
return data;
},

async delete(repositoryId: string, branch: string): Promise<void> {
await api.delete(`/repositories/${repositoryId}/branch-protection/${encodeURIComponent(branch)}`);
},
};
59 changes: 59 additions & 0 deletions luxview-dashboard/src/components/common/CodeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

const EXT_LANG: Record<string, string> = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript', py: 'python', go: 'go', rs: 'rust',
java: 'java', rb: 'ruby', php: 'php', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp',
hpp: 'cpp', cs: 'csharp', json: 'json', yml: 'yaml', yaml: 'yaml',
md: 'markdown', sh: 'bash', bash: 'bash', zsh: 'bash', sql: 'sql',
html: 'xml', xml: 'xml', css: 'css', scss: 'scss', less: 'less',
kt: 'kotlin', swift: 'swift', toml: 'ini', ini: 'ini', dockerfile: 'dockerfile',
};

function langFor(fileName: string): string | undefined {
const lower = fileName.toLowerCase();
if (lower === 'dockerfile') return 'dockerfile';
const ext = lower.includes('.') ? lower.split('.').pop()! : '';
const lang = EXT_LANG[ext];
return lang && hljs.getLanguage(lang) ? lang : undefined;
}

interface CodeViewProps {
content: string;
fileName: string;
}

// CodeView renders a source file with syntax highlighting and a line-number gutter.
export function CodeView({ content, fileName }: CodeViewProps) {
const html = useMemo(() => {
const lang = langFor(fileName);
try {
return lang
? hljs.highlight(content, { language: lang }).value
: hljs.highlightAuto(content).value;
} catch {
return null;
}
}, [content, fileName]);

const lineCount = content.split('\n').length;

return (
<div className="flex text-xs font-mono leading-5 overflow-x-auto">
<div className="select-none text-right pr-4 pl-3 py-4 text-zinc-600 flex-shrink-0">
{Array.from({ length: lineCount }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre className="hljs flex-1 py-4 pr-4 !bg-transparent">
{/* Safe: highlight.js HTML-escapes the source text and only injects its own
<span> markup, so no untrusted HTML reaches the DOM. */}
{html !== null
? <code dangerouslySetInnerHTML={{ __html: html }} />
: <code>{content}</code>}
</pre>
</div>
);
}
29 changes: 29 additions & 0 deletions luxview-dashboard/src/components/common/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
import './markdown.css';

interface MarkdownProps {
children: string;
className?: string;
}

// Markdown renders user content (READMEs, issue/PR bodies, comments) as GitHub-flavored
// markdown with syntax-highlighted code blocks. Raw HTML is escaped by react-markdown,
// so untrusted input is safe to render.
export function Markdown({ children, className }: MarkdownProps) {
return (
<div className={`lv-markdown ${className ?? ''}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
a: ({ ...props }) => <a {...props} target="_blank" rel="noreferrer noopener" />,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
Loading
Loading