diff --git a/app.json b/app.json index 6a0ea7c..7ee6015 100644 --- a/app.json +++ b/app.json @@ -5,12 +5,13 @@ "version": "0.1.0", "orientation": "portrait", "userInterfaceStyle": "dark", - "backgroundColor": "#0f1117", + "icon": "./assets/icon.png", + "backgroundColor": "#1B2433", "scheme": "sheethappens", "android": { "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#0f1117" + "backgroundColor": "#1B2433" }, "package": "fr.gyozamancave.sheethappens", "permissions": [ diff --git a/app/_layout.tsx b/app/_layout.tsx index 2e56cf7..dedeab5 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { Pressable, Text } from "react-native"; import { Stack, router } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -28,7 +29,20 @@ export default function RootLayout() { animation: "slide_from_right", }} > - + ( + router.push("/settings")} + style={{ paddingLeft: 12, paddingRight: 4, paddingVertical: 6 }} + > + + + ), + }} + /> diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx index 185bc06..8980e14 100644 --- a/app/incident/[id].tsx +++ b/app/incident/[id].tsx @@ -12,7 +12,8 @@ import * as Clipboard from "expo-clipboard"; import * as SecureStore from "expo-secure-store"; import { getIncidentById, updateIncident } from "@/lib/db"; import { renderMarkdown } from "@/lib/markdown"; -import { pushIncidentToGitea } from "@/lib/gitea"; +import { pushIncidentToGit } from "@/lib/git"; +import type { GitProvider } from "@/lib/git"; import type { Incident } from "@/types/incident"; import { Colors } from "@/constants/theme"; @@ -59,28 +60,62 @@ export default function IncidentDetailScreen() { setIncident({ ...incident, status: "resolved" }); } - async function handleGiteaPush() { + async function handleGitPush() { if (!incident) return; - const [url, token, owner, repo, template] = await Promise.all([ - SecureStore.getItemAsync("gitea_url"), - SecureStore.getItemAsync("gitea_token"), - SecureStore.getItemAsync("gitea_owner"), - SecureStore.getItemAsync("gitea_repo"), - SecureStore.getItemAsync("md_template"), - ]); - if (!url || !token || !owner || !repo) { - Alert.alert("Gitea not configured", "Set up Gitea in Settings first."); + const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] = + await Promise.all([ + SecureStore.getItemAsync("git_provider"), + SecureStore.getItemAsync("md_template"), + SecureStore.getItemAsync("gitea_url"), + SecureStore.getItemAsync("gitea_token"), + SecureStore.getItemAsync("gitea_owner"), + SecureStore.getItemAsync("gitea_repo"), + SecureStore.getItemAsync("github_token"), + SecureStore.getItemAsync("github_owner"), + SecureStore.getItemAsync("github_repo"), + SecureStore.getItemAsync("gitlab_url"), + SecureStore.getItemAsync("gitlab_token"), + SecureStore.getItemAsync("gitlab_owner"), + SecureStore.getItemAsync("gitlab_repo"), + ]); + + const p = (provider ?? "gitea") as GitProvider; + + let url: string | undefined; + let token: string | null; + let owner: string | null; + let repo: string | null; + + if (p === "gitea") { + url = giteaUrl ?? undefined; + token = giteaToken; + owner = giteaOwner; + repo = giteaRepo; + } else if (p === "github") { + token = githubToken; + owner = githubOwner; + repo = githubRepo; + } else { + url = gitlabUrl ?? undefined; + token = gitlabToken; + owner = gitlabOwner; + repo = gitlabRepo; + } + + if (!token || !owner || !repo) { + Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`); return; } + setPushing(true); - const result = await pushIncidentToGitea( + const result = await pushIncidentToGit( incident, - { url, token, owner, repo }, + { provider: p, url, token, owner, repo }, template ?? undefined ); setPushing(false); if (result.success) { - Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful."); + Alert.alert("Pushed", result.url ?? "Push successful."); } else { Alert.alert("Push failed", result.error ?? "Unknown error"); } @@ -117,7 +152,7 @@ export default function IncidentDetailScreen() { - {pushing ? "Pushing…" : "Push to Gitea"} + {pushing ? "Pushing…" : "Push to Git"} diff --git a/app/index.tsx b/app/index.tsx index ec85399..3d87170 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -100,7 +100,7 @@ export default function HomeScreen() { backgroundColor: item.status === "open" ? "#f59e0b20" - : "#22c55e20", + : "#88D65620", borderRadius: 4, paddingHorizontal: 8, paddingVertical: 2, diff --git a/app/settings.tsx b/app/settings.tsx index 186f6d2..5e06173 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -6,22 +6,33 @@ import { ScrollView, Switch, Pressable, - Alert, } from "react-native"; import * as SecureStore from "expo-secure-store"; import { Colors } from "@/constants/theme"; import { DEFAULT_TEMPLATE } from "@/lib/markdown"; +import type { GitProvider } from "@/lib/git"; const KEYS = { INPUT_MODE: "pref_input_mode", HOME_VIEW: "pref_home_view", AI_ENABLED: "pref_ai_enabled", AI_KEY: "pref_ai_key", + MD_TEMPLATE: "md_template", + GIT_PROVIDER: "git_provider", + // Gitea GITEA_URL: "gitea_url", GITEA_TOKEN: "gitea_token", GITEA_OWNER: "gitea_owner", GITEA_REPO: "gitea_repo", - MD_TEMPLATE: "md_template", + // GitHub + GITHUB_TOKEN: "github_token", + GITHUB_OWNER: "github_owner", + GITHUB_REPO: "github_repo", + // GitLab + GITLAB_URL: "gitlab_url", + GITLAB_TOKEN: "gitlab_token", + GITLAB_OWNER: "gitlab_owner", + GITLAB_REPO: "gitlab_repo", } as const; const LABEL = { @@ -53,40 +64,76 @@ const INPUT = { fontFamily: "monospace", } as const; +const PROVIDER_LABELS: Record = { + gitea: "Gitea", + github: "GitHub", + gitlab: "GitLab", +}; + export default function SettingsScreen() { const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [aiEnabled, setAiEnabled] = useState(false); const [aiKey, setAiKey] = useState(""); + const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE); + const [saved, setSaved] = useState(false); + + // Git provider + const [gitProvider, setGitProvider] = useState("gitea"); + // Gitea const [giteaUrl, setGiteaUrl] = useState(""); const [giteaToken, setGiteaToken] = useState(""); const [giteaOwner, setGiteaOwner] = useState(""); const [giteaRepo, setGiteaRepo] = useState(""); - const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE); - const [saved, setSaved] = useState(false); + // GitHub + const [githubToken, setGithubToken] = useState(""); + const [githubOwner, setGithubOwner] = useState(""); + const [githubRepo, setGithubRepo] = useState(""); + // GitLab + const [gitlabUrl, setGitlabUrl] = useState(""); + const [gitlabToken, setGitlabToken] = useState(""); + const [gitlabOwner, setGitlabOwner] = useState(""); + const [gitlabRepo, setGitlabRepo] = useState(""); useEffect(() => { (async () => { - const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([ - SecureStore.getItemAsync(KEYS.INPUT_MODE), - SecureStore.getItemAsync(KEYS.HOME_VIEW), - SecureStore.getItemAsync(KEYS.AI_ENABLED), - SecureStore.getItemAsync(KEYS.AI_KEY), - SecureStore.getItemAsync(KEYS.GITEA_URL), - SecureStore.getItemAsync(KEYS.GITEA_TOKEN), - SecureStore.getItemAsync(KEYS.GITEA_OWNER), - SecureStore.getItemAsync(KEYS.GITEA_REPO), - SecureStore.getItemAsync(KEYS.MD_TEMPLATE), - ]); + const [im, hv, ai, key, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] = + await Promise.all([ + SecureStore.getItemAsync(KEYS.INPUT_MODE), + SecureStore.getItemAsync(KEYS.HOME_VIEW), + SecureStore.getItemAsync(KEYS.AI_ENABLED), + SecureStore.getItemAsync(KEYS.AI_KEY), + SecureStore.getItemAsync(KEYS.MD_TEMPLATE), + SecureStore.getItemAsync(KEYS.GIT_PROVIDER), + SecureStore.getItemAsync(KEYS.GITEA_URL), + SecureStore.getItemAsync(KEYS.GITEA_TOKEN), + SecureStore.getItemAsync(KEYS.GITEA_OWNER), + SecureStore.getItemAsync(KEYS.GITEA_REPO), + SecureStore.getItemAsync(KEYS.GITHUB_TOKEN), + SecureStore.getItemAsync(KEYS.GITHUB_OWNER), + SecureStore.getItemAsync(KEYS.GITHUB_REPO), + SecureStore.getItemAsync(KEYS.GITLAB_URL), + SecureStore.getItemAsync(KEYS.GITLAB_TOKEN), + SecureStore.getItemAsync(KEYS.GITLAB_OWNER), + SecureStore.getItemAsync(KEYS.GITLAB_REPO), + ]); if (im) setInputMode(im as "voice" | "form"); if (hv) setHomeView(hv as "dashboard" | "capture"); if (ai) setAiEnabled(ai === "true"); if (key) setAiKey(key); + if (tpl) setMdTemplate(tpl); + if (gp) setGitProvider(gp as GitProvider); if (gu) setGiteaUrl(gu); if (gt) setGiteaToken(gt); if (go) setGiteaOwner(go); if (gr) setGiteaRepo(gr); - if (tpl) setMdTemplate(tpl); + if (ghu) setGithubToken(ghu); + if (ghown) setGithubOwner(ghown); + if (ghrepo) setGithubRepo(ghrepo); + if (glu) setGitlabUrl(glu); + if (glt) setGitlabToken(glt); + if (glo) setGitlabOwner(glo); + if (glr) setGitlabRepo(glr); })(); }, []); @@ -96,11 +143,19 @@ export default function SettingsScreen() { SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView), SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)), SecureStore.setItemAsync(KEYS.AI_KEY, aiKey), + SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), + SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider), SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl), SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken), SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner), SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo), - SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), + SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken), + SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner), + SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo), + SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl), + SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken), + SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner), + SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo), ]); setSaved(true); setTimeout(() => setSaved(false), 2000); @@ -171,8 +226,8 @@ export default function SettingsScreen() { > @@ -185,6 +240,38 @@ export default function SettingsScreen() { ); } + function Field({ + label, + value, + onChange, + placeholder, + secure, + url, + }: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + secure?: boolean; + url?: boolean; + }) { + return ( + <> + {label} + + + ); + } + return ( {aiEnabled && ( + + )} + + Git Repository + + setGitProvider(v as GitProvider)} + options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({ + key: p, + label: PROVIDER_LABELS[p], + }))} + /> + + {gitProvider === "gitea" && ( <> - API Key - + + + )} - Gitea - Instance URL - - Token - - Owner - - Repository - + {gitProvider === "github" && ( + <> + + + Uses api.github.com — no instance URL required. + + + + + + + )} + + {gitProvider === "gitlab" && ( + <> + + + + + + )} Markdown Template - + {saved ? "Saved ✓" : "Save Settings"} diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index 154ac68..8de4148 100644 Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..05c50db Binary files /dev/null and b/assets/icon.png differ diff --git a/constants/theme.ts b/constants/theme.ts index ac80a39..a458332 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -1,16 +1,16 @@ export const Colors = { - bg: "#0f1117", - surface: "#1a1d27", - surface2: "#252836", - primary: "#6366f1", - primaryDim: "#4338ca", - success: "#22c55e", - warning: "#f59e0b", - danger: "#ef4444", - text1: "#f1f5f9", - text2: "#94a3b8", - textDim: "#64748b", - border: "#2d3147", + bg: "#1B2433", + surface: "#222D3D", + surface2: "#293648", + primary: "#88D656", + primaryDim: "#5FA33A", + success: "#88D656", + warning: "#F5A623", + danger: "#EF4444", + text1: "#EEF2F5", + text2: "#8FA5BF", + textDim: "#4E6480", + border: "#2E3F55", } as const; export const FontFamily = { diff --git a/lib/git.ts b/lib/git.ts new file mode 100644 index 0000000..24af9b5 --- /dev/null +++ b/lib/git.ts @@ -0,0 +1,174 @@ +import { incidentToFilename, renderMarkdown } from "./markdown"; +import type { Incident } from "@/types/incident"; + +export type GitProvider = "gitea" | "github" | "gitlab"; + +export interface GitConfig { + provider: GitProvider; + url?: string; // required for gitea and self-hosted gitlab + token: string; + owner: string; + repo: string; +} + +export interface PushResult { + success: boolean; + url?: string; + error?: string; +} + +export async function pushIncidentToGit( + incident: Incident, + config: GitConfig, + markdownTemplate?: string +): Promise { + switch (config.provider) { + case "gitea": + return pushGitea(incident, config, markdownTemplate); + case "github": + return pushGitHub(incident, config, markdownTemplate); + case "gitlab": + return pushGitLab(incident, config, markdownTemplate); + } +} + +async function pushGitea( + incident: Incident, + config: GitConfig, + markdownTemplate?: string +): Promise { + const filepath = incidentToFilename(incident); + const content = renderMarkdown(incident, markdownTemplate); + const base64Content = btoa(unescape(encodeURIComponent(content))); + const apiBase = (config.url ?? "").replace(/\/$/, ""); + const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`; + + try { + const existing = await fetch(endpoint, { + headers: { Authorization: `Bearer ${config.token}` }, + }); + let sha: string | undefined; + if (existing.ok) { + const data = await existing.json() as { sha?: string }; + sha = data.sha; + } + + const body: Record = { + message: `incident: ${incident.title}`, + content: base64Content, + }; + if (sha) body.sha = sha; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; + } + const data = await res.json() as { content?: { html_url?: string } }; + return { success: true, url: data.content?.html_url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +async function pushGitHub( + incident: Incident, + config: GitConfig, + markdownTemplate?: string +): Promise { + const filepath = incidentToFilename(incident); + const content = renderMarkdown(incident, markdownTemplate); + const base64Content = btoa(unescape(encodeURIComponent(content))); + const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`; + + try { + const existing = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${config.token}`, + Accept: "application/vnd.github+json", + }, + }); + let sha: string | undefined; + if (existing.ok) { + const data = await existing.json() as { sha?: string }; + sha = data.sha; + } + + const body: Record = { + message: `incident: ${incident.title}`, + content: base64Content, + }; + if (sha) body.sha = sha; + + const res = await fetch(endpoint, { + method: "PUT", + headers: { + Authorization: `Bearer ${config.token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; + } + const data = await res.json() as { content?: { html_url?: string } }; + return { success: true, url: data.content?.html_url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +async function pushGitLab( + incident: Incident, + config: GitConfig, + markdownTemplate?: string +): Promise { + const filepath = incidentToFilename(incident); + const content = renderMarkdown(incident, markdownTemplate); + const base64Content = btoa(unescape(encodeURIComponent(content))); + const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, ""); + const projectId = encodeURIComponent(`${config.owner}/${config.repo}`); + const encodedPath = encodeURIComponent(filepath); + const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`; + + try { + const existing = await fetch(`${endpoint}?ref=HEAD`, { + headers: { "PRIVATE-TOKEN": config.token }, + }); + const method = existing.ok ? "PUT" : "POST"; + + const res = await fetch(endpoint, { + method, + headers: { + "PRIVATE-TOKEN": config.token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + branch: "main", + content: base64Content, + encoding: "base64", + commit_message: `incident: ${incident.title}`, + }), + }); + + if (!res.ok) { + return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; + } + const data = await res.json() as { file_path?: string }; + const webUrl = data.file_path + ? `${apiBase}/${config.owner}/${config.repo}/-/blob/main/${data.file_path}` + : undefined; + return { success: true, url: webUrl }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +}