Skip to content

Commit 1e48d22

Browse files
committed
Implement GitHub integration for loading and committing metadata files
1 parent 6e775ba commit 1e48d22

7 files changed

Lines changed: 707 additions & 5 deletions

File tree

src/app/components/file-selector/file-selector.component.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,74 @@
168168
animation: spin 0.8s linear infinite;
169169
}
170170

171+
.spinner.small {
172+
width: 16px;
173+
height: 16px;
174+
border-width: 2px;
175+
}
176+
177+
.github-panel {
178+
width: 100%;
179+
max-width: 900px;
180+
background: color-mix(in srgb, var(--color-bg-secondary) 95%, transparent);
181+
border: 1px solid var(--color-border);
182+
border-radius: var(--radius-xl);
183+
padding: var(--space-6);
184+
box-shadow: var(--shadow-md);
185+
display: flex;
186+
flex-direction: column;
187+
gap: var(--space-4);
188+
}
189+
190+
.github-panel-header h2 {
191+
margin: 0 0 var(--space-1) 0;
192+
font-size: var(--font-size-md);
193+
color: var(--color-text-primary);
194+
}
195+
196+
.github-panel-header p {
197+
margin: 0;
198+
color: var(--color-text-secondary);
199+
font-size: var(--font-size-sm);
200+
}
201+
202+
.github-form {
203+
display: grid;
204+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
205+
gap: var(--space-4);
206+
}
207+
208+
.github-form .field {
209+
display: flex;
210+
flex-direction: column;
211+
gap: var(--space-2);
212+
font-size: var(--font-size-xs);
213+
color: var(--color-text-secondary);
214+
}
215+
216+
.github-form input {
217+
padding: var(--space-3) var(--space-4);
218+
border-radius: var(--radius-md);
219+
border: 1px solid var(--color-border);
220+
background: var(--color-bg-elevated);
221+
color: var(--color-text-primary);
222+
}
223+
224+
.github-form small {
225+
color: var(--color-text-secondary);
226+
}
227+
228+
.github-actions {
229+
display: flex;
230+
align-items: center;
231+
gap: var(--space-3);
232+
}
233+
234+
.github-status {
235+
font-size: var(--font-size-xs);
236+
color: var(--color-text-secondary);
237+
}
238+
171239
@keyframes spin {
172240
to {
173241
transform: rotate(360deg);

src/app/components/file-selector/file-selector.component.html

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,101 @@
136136
<span>For best experience, use Chrome or Edge. Files will need to be re-selected after refresh in this browser.</span>
137137
</div>
138138
}
139+
140+
<section class="github-panel" aria-label="Load from GitHub">
141+
<div class="github-panel-header">
142+
<h2>Load from GitHub</h2>
143+
<p>Fetch only metadata files without cloning the full repo.</p>
144+
</div>
145+
146+
@if (githubError()) {
147+
<div class="error-banner" role="alert">
148+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
149+
<circle cx="12" cy="12" r="10"></circle>
150+
<line x1="12" y1="8" x2="12" y2="12"></line>
151+
<line x1="12" y1="16" x2="12.01" y2="16"></line>
152+
</svg>
153+
<span>{{ githubError() }}</span>
154+
</div>
155+
}
156+
157+
<div class="github-form">
158+
<label class="field">
159+
<span>Repository</span>
160+
<input
161+
type="text"
162+
[value]="githubRepo()"
163+
(input)="onGithubRepoInput($event)"
164+
placeholder="owner/repo"
165+
autocomplete="off"
166+
/>
167+
</label>
168+
169+
<label class="field">
170+
<span>Branch</span>
171+
<input
172+
type="text"
173+
[value]="githubBranch()"
174+
(input)="onGithubBranchInput($event)"
175+
placeholder="master"
176+
autocomplete="off"
177+
/>
178+
</label>
179+
180+
<label class="field">
181+
<span>GitHub token</span>
182+
<input
183+
type="password"
184+
[value]="githubToken()"
185+
(input)="onGithubTokenInput($event)"
186+
placeholder="ghp_..."
187+
autocomplete="off"
188+
/>
189+
<small>Fine-grained token -> Only select repositories -> Contents: Read & Write</small>
190+
</label>
191+
192+
<label class="field">
193+
<span>Commit author name</span>
194+
<input
195+
type="text"
196+
[value]="githubAuthorName()"
197+
(input)="onGithubAuthorNameInput($event)"
198+
autocomplete="off"
199+
/>
200+
</label>
201+
202+
<label class="field">
203+
<span>Commit author email</span>
204+
<input
205+
type="email"
206+
[value]="githubAuthorEmail()"
207+
(input)="onGithubAuthorEmailInput($event)"
208+
autocomplete="off"
209+
/>
210+
<small>Email must be verified on GitHub to avoid noreply.</small>
211+
</label>
212+
</div>
213+
214+
<div class="github-actions">
215+
<button
216+
type="button"
217+
class="btn btn-primary"
218+
(click)="onLoadFromGithub()"
219+
[attr.aria-busy]="isGithubLoading()"
220+
>
221+
@if (isGithubLoading()) {
222+
<span class="spinner small"></span>
223+
} @else {
224+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
225+
<path d="M12 5v14"></path>
226+
<path d="M5 12h14"></path>
227+
</svg>
228+
}
229+
<span>Load from GitHub</span>
230+
</button>
231+
@if (githubStatus()) {
232+
<span class="github-status" role="status">{{ githubStatus() }}</span>
233+
}
234+
</div>
235+
</section>
139236
</div>

src/app/components/file-selector/file-selector.component.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { ChangeDetectionStrategy, Component, inject, signal, ElementRef, ViewChild } from '@angular/core';
1+
import { ChangeDetectionStrategy, Component, inject, signal, ElementRef, ViewChild, effect } from '@angular/core';
22
import { Router } from '@angular/router';
33
import { FileSystemService } from '../../services/file-system.service';
44
import { DataStateService } from '../../services/data-state.service';
55
import { SvgIconComponent } from "angular-svg-icon";
6+
import { GithubRepoService } from '../../services/github-repo.service';
67

78
type DropZone = 'folder' | 'obo' | 'diaf' | 'metadata';
89

@@ -16,6 +17,7 @@ type DropZone = 'folder' | 'obo' | 'diaf' | 'metadata';
1617
export class FileSelectorComponent {
1718
private fileSystemService = inject(FileSystemService);
1819
private dataStateService = inject(DataStateService);
20+
private githubRepoService = inject(GithubRepoService);
1921
private router = inject(Router);
2022

2123
@ViewChild('folderInput') folderInput!: ElementRef<HTMLInputElement>;
@@ -24,6 +26,21 @@ export class FileSelectorComponent {
2426
protected readonly supportsFileSystemAccess = this.fileSystemService.supportsFileSystemAccess;
2527
protected readonly isLoading = signal(false);
2628
protected readonly error = signal<string | null>(null);
29+
protected readonly githubError = signal<string | null>(null);
30+
protected readonly isGithubLoading = signal(false);
31+
protected readonly githubStatus = this.githubRepoService.statusMessage;
32+
33+
protected readonly githubRepo = signal('pegi3s/dockerfiles');
34+
protected readonly githubBranch = signal('master');
35+
protected readonly githubToken = signal('');
36+
protected readonly githubAuthorName = signal('');
37+
protected readonly githubAuthorEmail = signal('');
38+
39+
private readonly storageKeys = {
40+
githubToken: 'bdip.github.token',
41+
githubAuthorName: 'bdip.github.authorName',
42+
githubAuthorEmail: 'bdip.github.authorEmail',
43+
} as const;
2744

2845
// Individual drag states
2946
protected readonly isDragOverFolder = signal(false);
@@ -48,6 +65,134 @@ export class FileSelectorComponent {
4865
protected readonly currentFileAccept = signal('.json,.obo,.diaf');
4966
private currentFileType: 'obo' | 'diaf' | 'metadata' = 'metadata';
5067

68+
constructor() {
69+
this.restoreGithubPrefs();
70+
this.setupGithubPersistence();
71+
}
72+
73+
async onLoadFromGithub(): Promise<void> {
74+
this.githubError.set(null);
75+
this.isGithubLoading.set(true);
76+
77+
try {
78+
this.githubRepoService.setToken(this.githubToken());
79+
this.githubRepoService.setAuthor(
80+
this.githubAuthorName(),
81+
this.githubAuthorEmail()
82+
);
83+
const result = await this.githubRepoService.connectAndLoad(
84+
this.githubRepo(),
85+
this.githubBranch()
86+
);
87+
88+
this.dataStateService.loadData(result.metadata, result.obo, result.diaf, 'github');
89+
await this.router.navigate(['/metadata']);
90+
} catch (err) {
91+
this.githubError.set((err as Error).message);
92+
} finally {
93+
this.isGithubLoading.set(false);
94+
}
95+
}
96+
97+
onGithubRepoInput(event: Event): void {
98+
const target = event.target as HTMLInputElement | null;
99+
this.githubRepo.set(target?.value ?? '');
100+
}
101+
102+
onGithubBranchInput(event: Event): void {
103+
const target = event.target as HTMLInputElement | null;
104+
this.githubBranch.set(target?.value ?? '');
105+
}
106+
107+
onGithubTokenInput(event: Event): void {
108+
const target = event.target as HTMLInputElement | null;
109+
this.githubToken.set(target?.value ?? '');
110+
}
111+
112+
onGithubAuthorNameInput(event: Event): void {
113+
const target = event.target as HTMLInputElement | null;
114+
this.githubAuthorName.set(target?.value ?? '');
115+
}
116+
117+
onGithubAuthorEmailInput(event: Event): void {
118+
const target = event.target as HTMLInputElement | null;
119+
this.githubAuthorEmail.set(target?.value ?? '');
120+
}
121+
122+
private restoreGithubPrefs(): void {
123+
if (!this.isBrowser()) return;
124+
125+
const token = this.readSessionValue(this.storageKeys.githubToken);
126+
const authorName = this.readLocalValue(this.storageKeys.githubAuthorName);
127+
const authorEmail = this.readLocalValue(this.storageKeys.githubAuthorEmail);
128+
129+
if (token) this.githubToken.set(token);
130+
if (authorName) this.githubAuthorName.set(authorName);
131+
if (authorEmail) this.githubAuthorEmail.set(authorEmail);
132+
}
133+
134+
private setupGithubPersistence(): void {
135+
effect(() => {
136+
this.writeSessionValue(this.storageKeys.githubToken, this.githubToken());
137+
});
138+
139+
effect(() => {
140+
this.writeLocalValue(this.storageKeys.githubAuthorName, this.githubAuthorName());
141+
});
142+
143+
effect(() => {
144+
this.writeLocalValue(this.storageKeys.githubAuthorEmail, this.githubAuthorEmail());
145+
});
146+
}
147+
148+
private isBrowser(): boolean {
149+
return typeof window !== 'undefined';
150+
}
151+
152+
private readLocalValue(key: string): string {
153+
if (!this.isBrowser()) return '';
154+
try {
155+
return window.localStorage.getItem(key) ?? '';
156+
} catch {
157+
return '';
158+
}
159+
}
160+
161+
private writeLocalValue(key: string, value: string): void {
162+
if (!this.isBrowser()) return;
163+
try {
164+
if (!value) {
165+
window.localStorage.removeItem(key);
166+
} else {
167+
window.localStorage.setItem(key, value);
168+
}
169+
} catch {
170+
return;
171+
}
172+
}
173+
174+
private readSessionValue(key: string): string {
175+
if (!this.isBrowser()) return '';
176+
try {
177+
return window.sessionStorage.getItem(key) ?? '';
178+
} catch {
179+
return '';
180+
}
181+
}
182+
183+
private writeSessionValue(key: string, value: string): void {
184+
if (!this.isBrowser()) return;
185+
try {
186+
if (!value) {
187+
window.sessionStorage.removeItem(key);
188+
} else {
189+
window.sessionStorage.setItem(key, value);
190+
}
191+
} catch {
192+
return;
193+
}
194+
}
195+
51196
async onSelectFolder(): Promise<void> {
52197
// If File System Access API is supported, use it
53198
if (this.supportsFileSystemAccess()) {

src/app/components/layout/header.component.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ <h1 class="app-title">MetaEditor</h1>
4444
type="button"
4545
class="btn btn-primary"
4646
(click)="onSave()"
47-
[attr.aria-label]="fileSystem.canSaveDirectly() ? 'Save files' : 'Download files'"
47+
[attr.aria-label]="getSaveAriaLabel()"
4848
>
4949
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
50-
@if (fileSystem.canSaveDirectly()) {
50+
@if (dataState.sourceMode() === 'github') {
51+
<path d="M5 12l5 5L20 7"></path>
52+
} @else if (fileSystem.canSaveDirectly()) {
5153
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
5254
<polyline points="17 21 17 13 7 13 7 21"></polyline>
5355
<polyline points="7 3 7 8 15 8"></polyline>
@@ -57,7 +59,7 @@ <h1 class="app-title">MetaEditor</h1>
5759
<line x1="12" y1="15" x2="12" y2="3"></line>
5860
}
5961
</svg>
60-
{{ fileSystem.canSaveDirectly() ? 'Save' : 'Download' }}
62+
{{ getSaveLabel() }}
6163
</button>
6264

6365
<button

src/app/components/layout/header.component.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ export class HeaderComponent {
1616
protected readonly fileSystem = inject(FileSystemService);
1717
private readonly router = inject(Router);
1818

19+
protected getSaveLabel(): string {
20+
if (this.dataState.sourceMode() === 'github') {
21+
return 'Commit';
22+
}
23+
24+
return this.fileSystem.canSaveDirectly() ? 'Save' : 'Download';
25+
}
26+
27+
protected getSaveAriaLabel(): string {
28+
if (this.dataState.sourceMode() === 'github') {
29+
return 'Commit changes to GitHub';
30+
}
31+
32+
return this.fileSystem.canSaveDirectly() ? 'Save files' : 'Download files';
33+
}
34+
1935
async onSave(): Promise<void> {
2036
await this.dataState.saveAll();
2137
}

0 commit comments

Comments
 (0)