mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 15:34:54 +00:00
git activity update
This commit is contained in:
96
src/components/GitActivityGrid.jsx
Normal file
96
src/components/GitActivityGrid.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { getCachedGitActivity } from '../lib/git';
|
||||
import { formatDate, isToday, getWeekdayLabel } from '../lib/utils-habit';
|
||||
|
||||
const GitActivityGrid = () => {
|
||||
const [{ dailyCounts }, setData] = useState(() => getCachedGitActivity());
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const today = new Date();
|
||||
const todayDay = today.getDay();
|
||||
const daysSinceMonday = (todayDay + 6) % 7;
|
||||
const mondayThisWeek = new Date(today);
|
||||
mondayThisWeek.setDate(today.getDate() - daysSinceMonday);
|
||||
const weeksArray = [];
|
||||
const totalWeeks = 52;
|
||||
for (let week = totalWeeks - 1; week >= 0; week--) {
|
||||
const weekDays = [];
|
||||
const monday = new Date(mondayThisWeek);
|
||||
monday.setDate(mondayThisWeek.getDate() - week * 7);
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + day);
|
||||
weekDays.push(date);
|
||||
}
|
||||
weeksArray.push(weekDays);
|
||||
}
|
||||
return weeksArray;
|
||||
}, []);
|
||||
|
||||
const getOpacity = (count) => {
|
||||
if (!count) return 0.15;
|
||||
if (count < 2) return 0.35;
|
||||
if (count < 5) return 0.6;
|
||||
if (count < 10) return 0.8;
|
||||
return 1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Display current cache only; syncing is done from Settings
|
||||
setData(getCachedGitActivity());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 pt-0 shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center">
|
||||
<div className="mb-2 text-center w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
<h2 className="text-lg font-semibold">Git Activity</h2>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="overflow-x-auto grid-scroll mt-2 w-full flex justify-center">
|
||||
<div className="inline-flex gap-1 mb-4">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
<div className="h-3 text-xs text-muted-foreground text-center">
|
||||
{weekIndex % 4 === 0 && week[0].toLocaleDateString('en-US', { month: 'short' })}
|
||||
</div>
|
||||
{week.map((date, dayIndex) => {
|
||||
const dateStr = formatDate(date);
|
||||
const count = dailyCounts?.[dateStr] || 0;
|
||||
const isTodayCell = isToday(date);
|
||||
const isFuture = date > new Date();
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className="habit-cell w-3 h-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: '#3fb950',
|
||||
opacity: isFuture ? 0 : getOpacity(count),
|
||||
border: isTodayCell ? `2px solid #3fb950` : `1px solid #3fb95020`,
|
||||
pointerEvents: 'none',
|
||||
visibility: isFuture ? 'hidden' : 'visible',
|
||||
}}
|
||||
title={`${dateStr} • ${count} commits`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div key={day} className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-0">
|
||||
{getWeekdayLabel(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitActivityGrid;
|
||||
@@ -95,7 +95,8 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
))}
|
||||
{/* Weekday labels: Monday (top) to Sunday (bottom) */}
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
<div className="h-1" />
|
||||
{/* Spacer matches month label height to align rows */}
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
|
||||
264
src/lib/git.js
Normal file
264
src/lib/git.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// Git integrations library: manages sources, token encryption, fetching events, and caching
|
||||
import { formatDate } from './utils-habit';
|
||||
|
||||
const GIT_INT_KEY = 'habitgrid_git_integrations';
|
||||
const GIT_CACHE_KEY = 'habitgrid_git_cache';
|
||||
const GIT_ENABLED_KEY = 'habitgrid_git_enabled';
|
||||
const GIT_KEY_MATERIAL = 'habitgrid_git_k';
|
||||
|
||||
// --- Minimal AES-GCM encryption helpers using Web Crypto ---
|
||||
async function getCryptoKey() {
|
||||
try {
|
||||
let raw = localStorage.getItem(GIT_KEY_MATERIAL);
|
||||
if (!raw) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
raw = btoa(String.fromCharCode(...bytes));
|
||||
localStorage.setItem(GIT_KEY_MATERIAL, raw);
|
||||
}
|
||||
const buf = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
|
||||
return await crypto.subtle.importKey('raw', buf, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptToken(token) {
|
||||
const key = await getCryptoKey();
|
||||
if (!key) return token; // fallback
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = new TextEncoder().encode(token);
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc));
|
||||
return `${btoa(String.fromCharCode(...iv))}:${btoa(String.fromCharCode(...ct))}`;
|
||||
}
|
||||
|
||||
export async function decryptToken(tokenEnc) {
|
||||
const key = await getCryptoKey();
|
||||
if (!key || !tokenEnc.includes(':')) return tokenEnc || '';
|
||||
const [ivB64, ctB64] = tokenEnc.split(':');
|
||||
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
|
||||
const ct = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
|
||||
try {
|
||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||||
return new TextDecoder().decode(pt);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integrations CRUD ---
|
||||
export function getGitEnabled() {
|
||||
return localStorage.getItem(GIT_ENABLED_KEY) === 'true';
|
||||
}
|
||||
export function setGitEnabled(enabled) {
|
||||
localStorage.setItem(GIT_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
export function getIntegrations() {
|
||||
try {
|
||||
const raw = localStorage.getItem(GIT_INT_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addIntegration({ provider, baseUrl, username, token }) {
|
||||
const integrations = getIntegrations();
|
||||
const tokenEnc = await encryptToken(token);
|
||||
const id = Date.now().toString();
|
||||
integrations.push({ id, provider, baseUrl, username, tokenEnc, createdAt: new Date().toISOString() });
|
||||
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeIntegration(id) {
|
||||
const integrations = getIntegrations().filter(x => x.id !== id);
|
||||
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
||||
}
|
||||
|
||||
// --- Caching ---
|
||||
function getCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(GIT_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : { lastSync: null, dailyCounts: {} };
|
||||
} catch {
|
||||
return { lastSync: null, dailyCounts: {} };
|
||||
}
|
||||
}
|
||||
function setCache(cache) {
|
||||
localStorage.setItem(GIT_CACHE_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
export function getCachedGitActivity() {
|
||||
return getCache();
|
||||
}
|
||||
|
||||
// --- Fetch events per provider ---
|
||||
function isOlderThan(dateStr, days) {
|
||||
const d = new Date(dateStr);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return d < cutoff;
|
||||
}
|
||||
|
||||
function deriveGitHubGraphQLEndpoint(baseUrl) {
|
||||
try {
|
||||
const u = new URL(baseUrl || 'https://api.github.com');
|
||||
// If /api/v3 -> likely /api/graphql
|
||||
if (u.pathname.includes('/api/v3')) {
|
||||
return `${u.origin}/api/graphql`;
|
||||
}
|
||||
// If ends with /api -> /graphql under same base
|
||||
if (u.pathname.endsWith('/api')) {
|
||||
return `${u.origin}/graphql`;
|
||||
}
|
||||
// Default: append /graphql
|
||||
return `${baseUrl.replace(/\/$/, '')}/graphql`;
|
||||
} catch {
|
||||
return 'https://api.github.com/graphql';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubGraphQL({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
||||
if (!token) return null; // require token for GraphQL
|
||||
const endpoint = deriveGitHubGraphQLEndpoint(baseUrl);
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - days + 1);
|
||||
const query = `query($login:String!, $from:DateTime!, $to:DateTime!) {
|
||||
user(login:$login) {
|
||||
contributionsCollection(from:$from, to:$to) {
|
||||
contributionCalendar { weeks { contributionDays { date contributionCount } } }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { login: username, from: from.toISOString(), to: to.toISOString() } }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const daysArr = json?.data?.user?.contributionsCollection?.contributionCalendar?.weeks?.flatMap(w => w.contributionDays) || [];
|
||||
const counts = {};
|
||||
for (const d of daysArr) {
|
||||
if (d?.date) counts[d.date] = (counts[d.date] || 0) + (d.contributionCount || 0);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
||||
// Prefer GraphQL for full-year coverage if token present
|
||||
try {
|
||||
if (token) {
|
||||
const graphCounts = await fetchGitHubGraphQL({ baseUrl, username, token }, days);
|
||||
if (graphCounts) return graphCounts;
|
||||
}
|
||||
} catch {}
|
||||
const headers = { 'Accept': 'application/vnd.github+json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 3; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/users/${encodeURIComponent(username)}/events?per_page=100&page=${page}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const events = await res.json();
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
if (!ev || !ev.created_at) continue;
|
||||
if (isOlderThan(ev.created_at, days)) { page = 999; break; }
|
||||
if (ev.type === 'PushEvent') {
|
||||
const c = ev.payload?.size || 1;
|
||||
const day = formatDate(new Date(ev.created_at));
|
||||
counts[day] = (counts[day] || 0) + c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = `token ${token}`;
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 5; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(username)}/events?limit=50&page=${page}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const events = await res.json();
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
const created = ev?.created || ev?.created_at || ev?.timestamp;
|
||||
if (!created) continue;
|
||||
if (isOlderThan(created, days)) { page = 999; break; }
|
||||
const action = ev?.op_type || ev?.action || ev?.type;
|
||||
if (String(action).toLowerCase().includes('push')) {
|
||||
const day = formatDate(new Date(created));
|
||||
counts[day] = (counts[day] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
||||
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 5; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/api/v4/events?per_page=100&page=${page}&action=push`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const events = await res.json();
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
const created = ev?.created_at;
|
||||
if (!created) continue;
|
||||
if (isOlderThan(created, days)) { page = 999; break; }
|
||||
const day = formatDate(new Date(created));
|
||||
counts[day] = (counts[day] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function fetchAllGitActivity({ force = false, days = 365 } = {}) {
|
||||
const { lastSync, dailyCounts } = getCache();
|
||||
const last = lastSync ? new Date(lastSync) : null;
|
||||
const now = new Date();
|
||||
const withinDay = last && (now - last) < 24 * 60 * 60 * 1000;
|
||||
if (!force && withinDay && dailyCounts) {
|
||||
return { dailyCounts, lastSync };
|
||||
}
|
||||
|
||||
const integrations = getIntegrations();
|
||||
const perSource = [];
|
||||
for (const src of integrations) {
|
||||
const token = await decryptToken(src.tokenEnc);
|
||||
const info = { baseUrl: src.baseUrl, username: src.username, token };
|
||||
try {
|
||||
if (src.provider === 'github') {
|
||||
perSource.push(await fetchGitHubEvents(info, days));
|
||||
} else if (src.provider === 'gitlab') {
|
||||
perSource.push(await fetchGitLabEvents(info, days));
|
||||
} else if (src.provider === 'gitea' || src.provider === 'forgejo' || src.provider === 'custom') {
|
||||
perSource.push(await fetchGiteaLike(info, days));
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue other sources
|
||||
console.warn('Git fetch failed for', src.provider, e);
|
||||
}
|
||||
}
|
||||
// Merge
|
||||
const merged = {};
|
||||
for (const m of perSource) {
|
||||
for (const [day, cnt] of Object.entries(m)) {
|
||||
merged[day] = (merged[day] || 0) + cnt;
|
||||
}
|
||||
}
|
||||
const updated = { lastSync: new Date().toISOString(), dailyCounts: merged };
|
||||
setCache(updated);
|
||||
return updated;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-r
|
||||
import { Button } from '../components/ui/button';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import GitActivityGrid from '../components/GitActivityGrid';
|
||||
import { getGitEnabled } from '../lib/git';
|
||||
import { getHabits } from '../lib/storage';
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -12,12 +14,14 @@ const HomePage = () => {
|
||||
const { toast } = useToast();
|
||||
const [habits, setHabits] = useState([]);
|
||||
const [isPremium] = useState(false);
|
||||
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadHabits();
|
||||
setGitEnabled(getGitEnabled());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -113,6 +117,13 @@ const HomePage = () => {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Git Activity */}
|
||||
{gitEnabled && (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} className="mb-8">
|
||||
<GitActivityGrid />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Habits List */}
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2, Plus, Trash, GitBranch } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import { exportData, importData, clearAllData } from '../lib/storage';
|
||||
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,6 +16,11 @@ const SettingsPage = () => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
const [gitEnabled, setGitEnabledState] = useState(getGitEnabled());
|
||||
const [sources, setSources] = useState(() => getIntegrations());
|
||||
const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
|
||||
const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
@@ -30,6 +36,31 @@ const SettingsPage = () => {
|
||||
setDarkMode(enabled);
|
||||
};
|
||||
|
||||
const toggleGitEnabled = (enabled) => {
|
||||
setGitEnabledState(enabled);
|
||||
setGitEnabled(enabled);
|
||||
};
|
||||
|
||||
const handleAddSource = async () => {
|
||||
if (!form.username) return;
|
||||
const baseUrl = form.baseUrl || (form.provider === 'github' ? 'https://api.github.com' : form.provider === 'gitlab' ? 'https://gitlab.com' : '');
|
||||
await addIntegration({ provider: form.provider, baseUrl, username: form.username, token: form.token });
|
||||
setSources(getIntegrations());
|
||||
setForm({ provider: 'github', baseUrl: '', username: '', token: '' });
|
||||
};
|
||||
|
||||
const handleRemoveSource = (id) => {
|
||||
removeIntegration(id);
|
||||
setSources(getIntegrations());
|
||||
};
|
||||
|
||||
const handleSyncGit = async () => {
|
||||
setSyncing(true);
|
||||
const data = await fetchAllGitActivity({ force: true });
|
||||
setCacheInfo(data);
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const data = exportData();
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
@@ -118,6 +149,71 @@ const SettingsPage = () => {
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Integrations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"><GitBranch className="w-4 h-4" /> Integrations</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Label htmlFor="git-enabled" className="text-base">Show Git Activity</Label>
|
||||
<p className="text-sm text-muted-foreground">Display a unified Git activity grid</p>
|
||||
</div>
|
||||
<Switch id="git-enabled" checked={gitEnabled} onCheckedChange={toggleGitEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-4 gap-2 mb-3">
|
||||
<div>
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<select className="w-full bg-transparent border rounded-md p-2" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="forgejo">Forgejo</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Base URL</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="e.g. https://api.github.com" value={form.baseUrl} onChange={e => setForm({ ...form, baseUrl: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Username</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="your-username" value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Token</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="personal access token" value={form.token} onChange={e => setForm({ ...form, token: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddSource} className="mb-4 rounded-full"><Plus className="w-4 h-4 mr-1" /> Add Source</Button>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Button variant="outline" onClick={handleSyncGit} disabled={syncing} className="rounded-full">
|
||||
{syncing ? 'Syncing…' : 'Sync Git Data'}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{lastSync ? `Last sync: ${new Date(lastSync).toLocaleString()}` : ''}</span>
|
||||
</div>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sources.map(src => (
|
||||
<div key={src.id} className="flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-md p-2">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{src.provider} • {src.username}</div>
|
||||
<div className="text-xs text-muted-foreground">{src.baseUrl}</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleRemoveSource(src.id)} className="rounded-full" aria-label="Remove Source">
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
{/* Appearance */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
Reference in New Issue
Block a user