live action

This commit is contained in:
2025-11-26 19:58:46 +03:00
parent 732603559a
commit e07c5933ee
10 changed files with 349 additions and 159 deletions

View File

@@ -32,6 +32,29 @@ const io = new Server(server, {
} }
}); });
let counter = 0;
let counterTimer: NodeJS.Timeout | null = null;
const broadcastCounter = () => {
io.emit("counter:update", { value: counter });
};
const startCounter = () => {
if (counterTimer) return;
counterTimer = setInterval(() => {
counter += 1;
broadcastCounter();
}, 1000);
};
const stopCounter = () => {
if (counterTimer) {
clearInterval(counterTimer);
counterTimer = null;
}
io.emit("counter:stopped", { value: counter });
};
io.use((socket, next) => { io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined; const token = socket.handshake.auth?.token as string | undefined;
if (!token) { if (!token) {
@@ -51,6 +74,20 @@ io.on("connection", (socket) => {
socket.on("ping", () => { socket.on("ping", () => {
socket.emit("pong", "pong"); socket.emit("pong", "pong");
}); });
socket.on("counter:start", (ack?: (payload: { running: boolean; value: number }) => void) => {
startCounter();
ack?.({ running: true, value: counter });
});
socket.on("counter:stop", (ack?: (payload: { running: boolean; value: number }) => void) => {
stopCounter();
ack?.({ running: false, value: counter });
});
socket.on("counter:status", (ack?: (payload: { running: boolean; value: number }) => void) => {
ack?.({ running: !!counterTimer, value: counter });
});
}); });
async function start() { async function start() {

View File

@@ -1,17 +1,22 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { AdminPage } from "./pages/AdminPage";
import { ProtectedRoute } from "./components/ProtectedRoute"; import { ProtectedRoute } from "./components/ProtectedRoute";
import { DashboardLayout } from "./components/DashboardLayout";
import { HomePage } from "./pages/HomePage";
import { JobsPage } from "./pages/JobsPage";
function App() { function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/admin" element={<AdminPage />} /> <Route element={<DashboardLayout />}>
<Route path="/jobs" element={<AdminPage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/admin" replace />} /> </Route>
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes> </Routes>
); );
} }

View File

@@ -0,0 +1,98 @@
import React, { useMemo, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse, faBriefcase, faArrowRightFromBracket, faUser } from "@fortawesome/free-solid-svg-icons";
import { Button } from "./ui/button";
import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "../providers/auth-provider";
import { cn } from "../lib/utils";
import { apiClient } from "../api/client";
export function DashboardLayout() {
const { user, token, logout } = useAuth();
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
const navigation = useMemo(
() => [
{ label: "Home", to: "/home", icon: faHouse },
{ label: "Jobs", to: "/jobs", icon: faBriefcase }
],
[]
);
const socketUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
const handleLogout = () => {
setIsLoggingOut(true);
logout();
navigate("/login", { replace: true });
};
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex min-h-screen">
<aside className="hidden w-64 flex-col border-r border-border bg-card/40 md:flex">
<div className="flex h-16 items-center border-b border-border px-6">
<span className="text-lg font-semibold tracking-tight">Wisecolt CI</span>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)
}
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="mt-auto space-y-3 border-t border-border px-4 py-4">
<div className="flex gap-3">
<ThemeToggle size="icon" className="h-10 w-10 justify-center" />
<div className="flex h-10 flex-1 items-center gap-3 rounded-md border border-border bg-background px-3">
<FontAwesomeIcon icon={faUser} className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{user?.username ?? "-"}</span>
</div>
</div>
<Button
variant="outline"
className="w-full justify-center gap-2"
onClick={handleLogout}
disabled={isLoggingOut}
>
<FontAwesomeIcon icon={faArrowRightFromBracket} className="h-4 w-4" />
Çıkış Yap
</Button>
</div>
</aside>
<main className="flex-1">
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<header className="flex items-center justify-between rounded-md border border-border bg-card/60 px-4 py-3">
<div>
<div className="text-sm uppercase tracking-wide text-muted-foreground">Proje</div>
<div className="text-lg font-semibold text-foreground">Wisecolt CI</div>
</div>
<div className="text-xs text-muted-foreground">
Bağlantı: <span className="font-mono text-foreground">{socketUrl}</span>
</div>
</header>
<div className="grid gap-6">
<Outlet />
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -5,16 +5,22 @@ import App from "./App";
import "./index.css"; import "./index.css";
import { ThemeProvider } from "./providers/theme-provider"; import { ThemeProvider } from "./providers/theme-provider";
import { AuthProvider } from "./providers/auth-provider"; import { AuthProvider } from "./providers/auth-provider";
import { SocketProvider } from "./providers/socket-provider";
import { LiveProvider } from "./providers/live-provider";
import { Toaster } from "./components/ui/toaster"; import { Toaster } from "./components/ui/toaster";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<SocketProvider>
<LiveProvider>
<BrowserRouter> <BrowserRouter>
<App /> <App />
<Toaster /> <Toaster />
</BrowserRouter> </BrowserRouter>
</LiveProvider>
</SocketProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>

View File

@@ -1,150 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse, faBriefcase, faArrowRightFromBracket, faUser } from "@fortawesome/free-solid-svg-icons";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { ThemeToggle } from "../components/ThemeToggle";
import { useAuth } from "../providers/auth-provider";
import { apiClient } from "../api/client";
import { cn } from "../lib/utils";
export function AdminPage() {
const { user, token, logout } = useAuth();
const navigate = useNavigate();
const [socket, setSocket] = useState<Socket | null>(null);
const [messages, setMessages] = useState<string[]>([]);
const socketUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
useEffect(() => {
if (!token) return;
const newSocket = io(socketUrl, {
auth: { token },
transports: ["websocket", "polling"]
});
newSocket.on("connect", () => setMessages((prev) => [...prev, "Socket bağlandı"]));
newSocket.on("hello", (msg: string) => setMessages((prev) => [...prev, msg]));
newSocket.on("pong", (msg: string) => setMessages((prev) => [...prev, msg]));
newSocket.on("connect_error", (err) => {
setMessages((prev) => [...prev, `Hata: ${err.message}`]);
toast.error("Socket bağlantısı başarısız");
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, [token, socketUrl]);
const handleLogout = () => {
logout();
navigate("/login", { replace: true });
};
const sendPing = () => {
if (!socket) return;
socket.emit("ping");
};
const navigation = [
{ label: "Home", to: "/admin", icon: faHouse },
{ label: "Jobs", to: "/jobs", icon: faBriefcase }
];
return (
<div className="min-h-screen bg-background text-foreground">
<div className="flex min-h-screen">
<aside className="hidden w-64 flex-col border-r border-border bg-card/40 md:flex">
<div className="flex h-16 items-center border-b border-border px-6">
<span className="text-lg font-semibold tracking-tight">Wisecolt CI</span>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)
}
>
<FontAwesomeIcon icon={item.icon} className="h-4 w-4" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="mt-auto space-y-3 border-t border-border px-4 py-4">
<div className="flex gap-3">
<ThemeToggle size="icon" className="h-10 w-10 justify-center" />
<div className="flex h-10 flex-1 items-center gap-3 rounded-md border border-border bg-background px-3">
<FontAwesomeIcon icon={faUser} className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{user?.username ?? "-"}</span>
</div>
</div>
<Button variant="outline" className="w-full justify-center gap-2" onClick={handleLogout}>
<FontAwesomeIcon icon={faArrowRightFromBracket} className="h-4 w-4" />
Çıkış Yap
</Button>
</div>
</aside>
<main className="flex-1">
<div className="mx-auto grid max-w-5xl gap-6 px-6 py-10 md:grid-cols-2">
<Card className="border-border card-shadow">
<CardHeader>
<CardTitle>Bağlantı</CardTitle>
<CardDescription>Backend'e bağlanıp basit ping/pong testi yapın.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={sendPing} disabled={!socket}>
Ping Gönder
</Button>
<div className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
<div className="mb-2 text-xs uppercase tracking-wide text-foreground">Socket Mesajları</div>
<div className="space-y-1">
{messages.length === 0 && <div>Mesaj yok.</div>}
{messages.map((msg, idx) => (
<div key={idx} className="text-foreground/80">
{msg}
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="border-border card-shadow">
<CardHeader>
<CardTitle>Durum</CardTitle>
<CardDescription>Auth & bağlantı bilgileri.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>API URL</span>
<span className="font-medium text-foreground">{socketUrl}</span>
</div>
<div className="flex items-center justify-between">
<span>Kullanıcı</span>
<span className="font-medium text-foreground">{user?.username ?? "-"}</span>
</div>
<div className="flex items-center justify-between">
<span>Token</span>
<span className="truncate font-mono text-foreground/80">{token?.slice(0, 24) ?? "-"}...</span>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { useLiveCounter } from "../providers/live-provider";
export function HomePage() {
const { value, running, startCounter, stopCounter } = useLiveCounter();
return (
<Card className="border-border card-shadow">
<CardHeader>
<CardTitle>Canlı Sayaç</CardTitle>
<CardDescription>
Sayaç sunucu tarafından çalışır; başlattıktan sonra diğer sayfalardan anlık izleyebilirsiniz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border bg-muted/40 px-4 py-3">
<div>
<div className="text-sm text-muted-foreground">Durum</div>
<div className="text-lg font-semibold text-foreground">
{running ? "Çalışıyor" : "Beklemede"}
</div>
</div>
<div className="text-right">
<div className="text-sm text-muted-foreground">Değer</div>
<div className="text-3xl font-bold text-foreground">{value}</div>
</div>
</div>
<div className="flex gap-3">
<Button className="flex-1" onClick={startCounter} disabled={running}>
Sayaçı Başlat
</Button>
<Button className="flex-1" variant="outline" onClick={stopCounter} disabled={!running}>
Durdur
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { useLiveCounter } from "../providers/live-provider";
export function JobsPage() {
const { value, running } = useLiveCounter();
return (
<Card className="border-border card-shadow">
<CardHeader>
<CardTitle>Jobs Durumu</CardTitle>
<CardDescription>Diğer sayfalarda başlatılan canlı sayaç burada izlenebilir.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Canlı Durum</span>
<span className="font-semibold text-foreground">
{running ? "Çalışıyor" : "Beklemede"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Sayaç Değeri</span>
<span className="font-mono text-lg text-foreground">{value}</span>
</div>
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Sayaç Home sayfasından başlatılır. Bu panel aynı anda ık olan tüm kullanıcılarda anlık güncellenir.
</div>
</CardContent>
</Card>
);
}

View File

@@ -20,7 +20,7 @@ export function LoginPage() {
setLoading(true); setLoading(true);
try { try {
await login(username, password); await login(username, password);
navigate("/admin", { replace: true }); navigate("/home", { replace: true });
} catch (err) { } catch (err) {
toast.error("Giriş başarısız. Bilgileri kontrol edin."); toast.error("Giriş başarısız. Bilgileri kontrol edin.");
} finally { } finally {

View File

@@ -0,0 +1,75 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { useSocket } from "./socket-provider";
type LiveState = {
value: number;
running: boolean;
};
type LiveContextValue = LiveState & {
startCounter: () => void;
stopCounter: () => void;
};
const LiveContext = createContext<LiveContextValue | undefined>(undefined);
export const LiveProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const socket = useSocket();
const [state, setState] = useState<LiveState>({ value: 0, running: false });
useEffect(() => {
if (!socket) return;
const handleUpdate = (payload: { value: number }) => {
setState({ value: payload.value, running: true });
};
const handleStopped = (payload: { value: number }) => {
setState({ value: payload.value, running: false });
};
socket.on("counter:update", handleUpdate);
socket.on("counter:stopped", handleStopped);
socket.emit("counter:status", (payload: { value: number; running: boolean }) => {
setState({ value: payload.value, running: payload.running });
});
return () => {
socket.off("counter:update", handleUpdate);
socket.off("counter:stopped", handleStopped);
};
}, [socket]);
const startCounter = useMemo(
() => () => {
socket?.emit("counter:start");
},
[socket]
);
const stopCounter = useMemo(
() => () => {
socket?.emit("counter:stop");
},
[socket]
);
const value = useMemo(
() => ({
value: state.value,
running: state.running,
startCounter,
stopCounter
}),
[state, startCounter, stopCounter]
);
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
};
export function useLiveCounter() {
const ctx = useContext(LiveContext);
if (!ctx) throw new Error("useLiveCounter LiveProvider içinde kullanılmalı");
return ctx;
}

View File

@@ -0,0 +1,49 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import { useAuth } from "./auth-provider";
import { apiClient } from "../api/client";
const SocketContext = createContext<Socket | null>(null);
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { token } = useAuth();
const socketRef = useRef<Socket | null>(null);
const [ready, setReady] = useState(false);
const baseUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
useEffect(() => {
if (!token) {
socketRef.current?.disconnect();
socketRef.current = null;
setReady(false);
return;
}
const socket = io(baseUrl, {
auth: { token },
transports: ["websocket", "polling"]
});
socketRef.current = socket;
socket.on("connect", () => setReady(true));
socket.on("disconnect", () => setReady(false));
return () => {
socket.off("connect");
socket.off("disconnect");
socket.disconnect();
socketRef.current = null;
setReady(false);
};
}, [token, baseUrl]);
const value = ready ? socketRef.current : null;
return <SocketContext.Provider value={value}>{children}</SocketContext.Provider>;
};
export function useSocket() {
return useContext(SocketContext);
}