diff --git a/App.jsx b/App.jsx new file mode 100644 index 0000000..406dac7 --- /dev/null +++ b/App.jsx @@ -0,0 +1,125 @@ +import { onMount } from 'solid-js'; +import { createMutable } from 'solid-js/store'; +import { AppRoute } from './types.js'; +import { TokenGate } from './views/TokenGate'; +import { Dashboard } from './views/Dashboard'; +import { RepoDetail } from './views/RepoDetail'; +import { IssueDetail } from './views/IssueDetail'; +import { signOutFromFirebase, handleRedirectResult } from './services/firebaseService'; +import { validateToken } from './services/githubService'; +import { ThemeProvider } from './contexts/ThemeContext'; + +function App() { + const state = createMutable({ + token: localStorage.getItem('gh_token'), + user: localStorage.getItem('gh_user') ? JSON.parse(localStorage.getItem('gh_user')) : null, + checkingRedirect: true, + currentRoute: localStorage.getItem('gh_token') && localStorage.getItem('gh_user') ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT, + selectedRepo: null, + selectedIssue: null, + }); + + onMount(async () => { + try { + const result = await handleRedirectResult(); + if (result) { + const ghUser = await validateToken(result.accessToken); + handleLogin(result.accessToken, ghUser); + } + } catch (err) { + console.error('Redirect result error:', err); + } finally { + state.checkingRedirect = false; + } + }); + + const handleLogin = (newToken, newUser) => { + state.token = newToken; + state.user = newUser; + localStorage.setItem('gh_token', newToken); + localStorage.setItem('gh_user', JSON.stringify(newUser)); + state.currentRoute = AppRoute.REPO_LIST; + }; + + const handleLogout = async () => { + try { + await signOutFromFirebase(); + } catch (err) { + console.error('Firebase sign out error:', err); + } + + state.token = null; + state.user = null; + localStorage.removeItem('gh_token'); + localStorage.removeItem('gh_user'); + state.currentRoute = AppRoute.TOKEN_INPUT; + state.selectedRepo = null; + }; + + const navigateToRepo = (repo) => { + state.selectedRepo = repo; + state.currentRoute = AppRoute.REPO_DETAIL; + }; + + const navigateBack = () => { + state.selectedRepo = null; + state.selectedIssue = null; + state.currentRoute = AppRoute.REPO_LIST; + }; + + const navigateToIssue = (issue) => { + state.selectedIssue = issue; + state.currentRoute = AppRoute.ISSUE_DETAIL; + }; + + const navigateBackToRepo = () => { + state.selectedIssue = null; + state.currentRoute = AppRoute.REPO_DETAIL; + }; + + return ( + +
+ + }> + + + + + + + + + + + + + + +
+ ); +}; + +const AppWithProviders = () => ( + + + +); + +export default AppWithProviders; diff --git a/App.tsx b/App.tsx deleted file mode 100644 index 0d43b03..0000000 --- a/App.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { GitHubUser, Repository, Issue, AppRoute } from './types'; -import { TokenGate } from './views/TokenGate'; -import { Dashboard } from './views/Dashboard'; -import { RepoDetail } from './views/RepoDetail'; -import { IssueDetail } from './views/IssueDetail'; -import { signOutFromFirebase, handleRedirectResult } from './services/firebaseService'; -import { validateToken } from './services/githubService'; -import { ThemeProvider } from './contexts/ThemeContext'; - -const App: React.FC = () => { - const [token, setToken] = useState(localStorage.getItem('gh_token')); - const [user, setUser] = useState( - localStorage.getItem('gh_user') ? JSON.parse(localStorage.getItem('gh_user')!) : null - ); - const [checkingRedirect, setCheckingRedirect] = useState(true); - - const [currentRoute, setCurrentRoute] = useState( - token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT - ); - const [selectedRepo, setSelectedRepo] = useState(null); - const [selectedIssue, setSelectedIssue] = useState(null); - - // Handle redirect result from Firebase OAuth (for popup-blocked fallback) - useEffect(() => { - const checkRedirectResult = async () => { - try { - const result = await handleRedirectResult(); - if (result) { - // Validate token and get user data from GitHub API - const ghUser = await validateToken(result.accessToken); - handleLogin(result.accessToken, ghUser); - } - } catch (err) { - console.error('Redirect result error:', err); - } finally { - setCheckingRedirect(false); - } - }; - - checkRedirectResult(); - }, []); - - const handleLogin = (newToken: string, newUser: GitHubUser) => { - setToken(newToken); - setUser(newUser); - localStorage.setItem('gh_token', newToken); - localStorage.setItem('gh_user', JSON.stringify(newUser)); - setCurrentRoute(AppRoute.REPO_LIST); - }; - - const handleLogout = async () => { - // Sign out from Firebase - try { - await signOutFromFirebase(); - } catch (err) { - console.error('Firebase sign out error:', err); - } - - setToken(null); - setUser(null); - localStorage.removeItem('gh_token'); - localStorage.removeItem('gh_user'); - setCurrentRoute(AppRoute.TOKEN_INPUT); - setSelectedRepo(null); - }; - - const navigateToRepo = (repo: Repository) => { - setSelectedRepo(repo); - setCurrentRoute(AppRoute.REPO_DETAIL); - }; - - const navigateBack = () => { - setSelectedRepo(null); - setSelectedIssue(null); - setCurrentRoute(AppRoute.REPO_LIST); - }; - - const navigateToIssue = (issue: Issue) => { - setSelectedIssue(issue); - setCurrentRoute(AppRoute.ISSUE_DETAIL); - }; - - const navigateBackToRepo = () => { - setSelectedIssue(null); - setCurrentRoute(AppRoute.REPO_DETAIL); - }; - - // Render Logic - if (checkingRedirect) { - return ( -
-
-
- ); - } - - if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) { - return ; - } - - if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) { - return ( - - ); - } - - if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) { - return ( - - ); - } - - return ( - - ); -}; - -const AppWithProviders: React.FC = () => ( - - - -); - -export default AppWithProviders; diff --git a/components/Button.jsx b/components/Button.jsx new file mode 100644 index 0000000..c0c7163 --- /dev/null +++ b/components/Button.jsx @@ -0,0 +1,50 @@ +import { splitProps } from 'solid-js'; + +export const Button = (props) => { + const [local, others] = splitProps(props, [ + 'children', + 'variant', + 'size', + 'class', + 'isLoading', + 'icon', + ]); + + const variant = () => local.variant || 'primary'; + const size = () => local.size || 'md'; + + const baseStyles = "inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed"; + + const sizeStyles = { + sm: "px-2.5 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + }; + + const variants = { + primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", + secondary: "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 focus:ring-blue-500", + danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", + ghost: "bg-transparent text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 focus:ring-slate-500", + magic: "bg-gradient-to-r from-purple-500 to-indigo-600 text-white hover:from-purple-600 hover:to-indigo-700 focus:ring-purple-500", + }; + + return ( + + ); +}; diff --git a/components/Button.tsx b/components/Button.tsx deleted file mode 100644 index bfde209..0000000 --- a/components/Button.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'magic'; - size?: 'sm' | 'md'; - isLoading?: boolean; - icon?: React.ReactNode; -} - -export const Button: React.FC = ({ - children, - variant = 'primary', - size = 'md', - className = '', - isLoading = false, - icon, - ...props -}) => { - const baseStyles = "inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed"; - - const sizeStyles = { - sm: "px-2.5 py-1.5 text-xs", - md: "px-4 py-2 text-sm", - }; - - const variants = { - primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500", - secondary: "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700 focus:ring-blue-500", - danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", - ghost: "bg-transparent text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 focus:ring-slate-500", - magic: "bg-gradient-to-r from-purple-500 to-indigo-600 text-white hover:from-purple-600 hover:to-indigo-700 focus:ring-purple-500", - }; - - return ( - - ); -}; diff --git a/components/Markdown.tsx b/components/Markdown.jsx similarity index 83% rename from components/Markdown.tsx rename to components/Markdown.jsx index f950a20..53b511e 100644 --- a/components/Markdown.tsx +++ b/components/Markdown.jsx @@ -1,16 +1,11 @@ -import React from 'react'; -import ReactMarkdown from 'react-markdown'; +import { SolidMarkdown } from 'solid-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; -interface MarkdownProps { - children: string; - className?: string; -} - -export const Markdown: React.FC = ({ children, className = '' }) => { +export const Markdown = (props) => { + const className = () => props.class || ''; return ( -
= ({ children, className = '' }) [&_summary]:before:content-['▶'] [&_summary]:before:inline-block [&_summary]:before:mr-2 [&_summary]:before:text-xs [&_summary]:before:transition-transform [&_details[open]>summary]:before:content-['▼'] [&_details[open]>summary]:mb-2 - ${className}`}> - + ( {children} ), - // Better image handling img: ({ src, alt }) => ( {alt ), }} > - {children} - + {props.content} +
); }; diff --git a/components/RepoCard.jsx b/components/RepoCard.jsx new file mode 100644 index 0000000..3efea77 --- /dev/null +++ b/components/RepoCard.jsx @@ -0,0 +1,96 @@ +import { Star, Lock, Globe, Trash2, Pin, CircleDot } from 'lucide-solid'; + +export const RepoCard = (props) => { + const handleDeleteClick = (e) => { + e.stopPropagation(); + props.onDelete?.(props.repo); + }; + + const handlePinClick = (e) => { + e.stopPropagation(); + props.onPin?.(props.repo); + }; + + return ( +
props.onClick(props.repo)} + class="bg-white dark:bg-slate-800 p-5 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md dark:hover:shadow-slate-900/50 transition-shadow cursor-pointer flex flex-col h-full group" + > +
+
+ }> + + +

{props.repo.name}

+
+
+ + + + + + + + {props.repo.language || 'Text'} + +
+
+ +

+ {props.repo.description || "No description provided."} +

+ + 0}> +
+ {(issue) => +
{ + e.stopPropagation(); + window.open(issue.html_url, '_blank'); + }} + > + + + {issue.title} + +
+ }
+
+
+ +
+
+ + {props.repo.stargazers_count} +
+
+
+ {new Date(props.repo.updated_at).toLocaleDateString()} +
+
+ {props.repo.open_issues_count} issues +
+
+
+ ); +}; diff --git a/components/RepoCard.tsx b/components/RepoCard.tsx deleted file mode 100644 index 305a834..0000000 --- a/components/RepoCard.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; -import { Repository, Issue } from '../types'; -import { Star, Lock, Globe, Trash2, Pin, CircleDot } from 'lucide-react'; - -interface RepoCardProps { - repo: Repository; - onClick: (repo: Repository) => void; - onDelete?: (repo: Repository) => void; - onPin?: (repo: Repository) => void; - isPinned?: boolean; - issues?: Issue[]; -} - -export const RepoCard: React.FC = ({ repo, onClick, onDelete, onPin, isPinned, issues }) => { - const handleDeleteClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onDelete?.(repo); - }; - - const handlePinClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onPin?.(repo); - }; - - return ( -
onClick(repo)} - className="bg-white dark:bg-slate-800 p-5 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md dark:hover:shadow-slate-900/50 transition-shadow cursor-pointer flex flex-col h-full group" - > -
-
- {repo.private ? : } -

{repo.name}

-
-
- {onPin && ( - - )} - {onDelete && ( - - )} - - {repo.language || 'Text'} - -
-
- -

- {repo.description || "No description provided."} -

- - {issues && issues.length > 0 && ( -
- {issues.map((issue) => ( -
{ - e.stopPropagation(); - window.open(issue.html_url, '_blank'); - }} - > - - - {issue.title} - -
- ))} -
- )} - -
-
- - {repo.stargazers_count} -
-
-
- {new Date(repo.updated_at).toLocaleDateString()} -
-
- {repo.open_issues_count} issues -
-
-
- ); -}; diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.jsx similarity index 60% rename from components/ThemeToggle.tsx rename to components/ThemeToggle.jsx index ba4cacd..14eda28 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.jsx @@ -1,14 +1,13 @@ -import React from 'react'; -import { Sun, Moon, Monitor } from 'lucide-react'; +import { Sun, Moon, Monitor } from 'lucide-solid'; import { useTheme } from '../contexts/ThemeContext'; -export const ThemeToggle: React.FC = () => { +export const ThemeToggle = () => { const { theme, setTheme } = useTheme(); const cycleTheme = () => { - if (theme === 'system') { + if (theme() === 'system') { setTheme('light'); - } else if (theme === 'light') { + } else if (theme() === 'light') { setTheme('dark'); } else { setTheme('system'); @@ -16,7 +15,7 @@ export const ThemeToggle: React.FC = () => { }; const getIcon = () => { - switch (theme) { + switch (theme()) { case 'light': return ; case 'dark': @@ -27,7 +26,7 @@ export const ThemeToggle: React.FC = () => { }; const getLabel = () => { - switch (theme) { + switch (theme()) { case 'light': return 'Light'; case 'dark': @@ -40,14 +39,11 @@ export const ThemeToggle: React.FC = () => { return ( ); }; - - - diff --git a/components/Toast.jsx b/components/Toast.jsx new file mode 100644 index 0000000..e55900b --- /dev/null +++ b/components/Toast.jsx @@ -0,0 +1,89 @@ +import { createSignal, onCleanup, For } from 'solid-js'; +import { X, CheckCircle2, AlertCircle, Info } from 'lucide-solid'; + +const Toast = (props) => { + const { toast, onDismiss } = props; + + const timer = setTimeout(() => { + onDismiss(toast.id); + }, 4000); + + onCleanup(() => clearTimeout(timer)); + + const getStyles = () => { + switch (toast.type) { + case 'success': + return 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'; + case 'error': + return 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'; + case 'info': + default: + return 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200'; + } + }; + + const getIcon = () => { + switch (toast.type) { + case 'success': + return ; + case 'error': + return ; + case 'info': + default: + return ; + } + }; + + return ( + + ); +}; + +export const ToastContainer = (props) => { + return ( + 0}> +
+ {(toast) => + + } +
+
+ ); +}; + +export const useToast = () => { + const [toasts, setToasts] = createSignal([]); + + const addToast = (type, message) => { + const id = `toast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + setToasts((prev) => [...prev, { id, type, message }]); + }; + + const dismissToast = (id) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + const showSuccess = (message) => addToast('success', message); + const showError = (message) => addToast('error', message); + const showInfo = (message) => addToast('info', message); + + return { + toasts, + dismissToast, + showSuccess, + showError, + showInfo, + }; +}; diff --git a/components/Toast.tsx b/components/Toast.tsx deleted file mode 100644 index d5a42ff..0000000 --- a/components/Toast.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { X, CheckCircle2, AlertCircle, Info } from 'lucide-react'; - -export type ToastType = 'success' | 'error' | 'info'; - -export interface ToastMessage { - id: string; - type: ToastType; - message: string; -} - -interface ToastProps { - toast: ToastMessage; - onDismiss: (id: string) => void; -} - -const Toast: React.FC = ({ toast, onDismiss }) => { - useEffect(() => { - const timer = setTimeout(() => { - onDismiss(toast.id); - }, 4000); - return () => clearTimeout(timer); - }, [toast.id, onDismiss]); - - const getStyles = () => { - switch (toast.type) { - case 'success': - return 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'; - case 'error': - return 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'; - case 'info': - default: - return 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200'; - } - }; - - const getIcon = () => { - switch (toast.type) { - case 'success': - return ; - case 'error': - return ; - case 'info': - default: - return ; - } - }; - - return ( -
- {getIcon()} - {toast.message} - -
- ); -}; - -interface ToastContainerProps { - toasts: ToastMessage[]; - onDismiss: (id: string) => void; -} - -export const ToastContainer: React.FC = ({ toasts, onDismiss }) => { - if (toasts.length === 0) return null; - - return ( -
- {toasts.map((toast) => ( - - ))} -
- ); -}; - -// Hook for managing toasts -export const useToast = () => { - const [toasts, setToasts] = useState([]); - - const addToast = (type: ToastType, message: string) => { - const id = `toast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - setToasts((prev) => [...prev, { id, type, message }]); - }; - - const dismissToast = (id: string) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }; - - const showSuccess = (message: string) => addToast('success', message); - const showError = (message: string) => addToast('error', message); - const showInfo = (message: string) => addToast('info', message); - - return { - toasts, - dismissToast, - showSuccess, - showError, - showInfo, - }; -}; diff --git a/contexts/ThemeContext.jsx b/contexts/ThemeContext.jsx new file mode 100644 index 0000000..5d021e2 --- /dev/null +++ b/contexts/ThemeContext.jsx @@ -0,0 +1,62 @@ +import { createContext, useContext, createEffect, createSignal } from 'solid-js'; + +const ThemeContext = createContext(); + +export const ThemeProvider = (props) => { + const [theme, setThemeState] = createSignal(localStorage.getItem('theme') || 'system'); + + const [resolvedTheme, setResolvedTheme] = createSignal('light'); + + createEffect(() => { + const updateResolvedTheme = () => { + if (theme() === 'system') { + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setResolvedTheme(isDark ? 'dark' : 'light'); + } else { + setResolvedTheme(theme()); + } + }; + + updateResolvedTheme(); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => { + if (theme() === 'system') { + updateResolvedTheme(); + } + }; + + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }); + + createEffect(() => { + if (resolvedTheme() === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }); + + const setTheme = (newTheme) => { + setThemeState(newTheme); + if (newTheme === 'system') { + localStorage.removeItem('theme'); + } else { + localStorage.setItem('theme', newTheme); + } + }; + + const store = { + theme, + setTheme, + }; + + return ( + + {props.children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/contexts/ThemeContext.tsx b/contexts/ThemeContext.tsx deleted file mode 100644 index b36a0a9..0000000 --- a/contexts/ThemeContext.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark' | 'system'; - -interface ThemeContextType { - theme: Theme; - resolvedTheme: 'light' | 'dark'; - setTheme: (theme: Theme) => void; -} - -const ThemeContext = createContext(undefined); - -export const useTheme = () => { - const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -}; - -interface ThemeProviderProps { - children: React.ReactNode; -} - -export const ThemeProvider: React.FC = ({ children }) => { - const [theme, setThemeState] = useState(() => { - const stored = localStorage.getItem('theme'); - if (stored === 'light' || stored === 'dark' || stored === 'system') { - return stored; - } - return 'system'; - }); - - const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => { - if (theme === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - return theme; - }); - - useEffect(() => { - const updateResolvedTheme = () => { - if (theme === 'system') { - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - setResolvedTheme(isDark ? 'dark' : 'light'); - } else { - setResolvedTheme(theme); - } - }; - - updateResolvedTheme(); - - // Listen for OS preference changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = () => { - if (theme === 'system') { - updateResolvedTheme(); - } - }; - - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); - }, [theme]); - - useEffect(() => { - // Update the DOM class - if (resolvedTheme === 'dark') { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - }, [resolvedTheme]); - - const setTheme = (newTheme: Theme) => { - setThemeState(newTheme); - if (newTheme === 'system') { - localStorage.removeItem('theme'); - } else { - localStorage.setItem('theme', newTheme); - } - }; - - return ( - - {children} - - ); -}; - - - diff --git a/index.css b/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/index.html b/index.html index 52be8c2..d4b0d8d 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,6 @@ GitGenius Hub - -