diff --git a/package.json b/package.json index 50a1639..9bc37af 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "", + "name": "pdsmanager", "type": "module", - "version": "0.0.1", + "version": "0.1.0", "scripts": { "dev": "astro dev", "build": "astro build", @@ -27,4 +27,4 @@ "devDependencies": { "daisyui": "^4.12.23" } -} \ No newline at end of file +} diff --git a/src/components/App.tsx b/src/components/App.tsx deleted file mode 100644 index 22a0c8d..0000000 --- a/src/components/App.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState } from "react"; -import { Settings } from "../lib/settings"; -import InviteCodes from "./InviteCodes"; - -export default function App() { - const [serviceUrl, setServiceUrl] = useState(Settings.getServiceUrl()); - const [adminPassword, setAdminPassword] = useState( - Settings.getAdminPassword(), - ); - const [isConfigured, setIsConfigured] = useState( - Boolean(Settings.getServiceUrl() && Settings.getAdminPassword()), - ); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - Settings.saveSettings(serviceUrl, adminPassword); - setIsConfigured(true); - }; - - const handleClearSettings = () => { - Settings.clearSettings(); - setServiceUrl(""); - setAdminPassword(""); - setIsConfigured(false); - }; - - if (!isConfigured) { - return ( -
-
-
-

- BlueSky PDS Manager -

-

- Enter your PDS credentials to get started -

-
-
- - setServiceUrl(e.target.value)} - placeholder="https://bsky.web.site" - required - className="input input-bordered w-full" - /> -
-
- - setAdminPassword(e.target.value)} - required - className="input input-bordered w-full" - /> -
-
- -
-
-
-
- ); - } - - return ( -
-
-
-
- BlueSky PDS Manager -
-
-
- -
    -
  • - -
  • -
-
-
-
-
-
- -
-
- ); -} diff --git a/src/components/InviteCodes.tsx b/src/components/InviteCodes.tsx deleted file mode 100644 index 8ad00cb..0000000 --- a/src/components/InviteCodes.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { useState, useEffect } from "react"; -import { Settings } from "../lib/settings"; - -interface InviteCodeUse { - usedBy: string; - usedAt: string; -} - -interface InviteCode { - code: string; - available: number; - disabled: boolean; - forAccount: string; - createdBy: string; - createdAt: string; - uses: InviteCodeUse[]; -} - -function getAuthHeader(adminPassword: string) { - const authString = `admin:${adminPassword}`; - const base64Auth = btoa(authString); - return `Basic ${base64Auth}`; -} - -export default function InviteCodes({ onLogout }: { onLogout: () => void }) { - const [codes, setCodes] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [showSettings, setShowSettings] = useState(false); - const [newUrl, setNewUrl] = useState(Settings.getServiceUrl()); - const [newPassword, setNewPassword] = useState(Settings.getAdminPassword()); - - const resetState = () => { - setCodes([]); - setError(null); - setLoading(false); - }; - - const sortCodes = (codes: InviteCode[]) => { - return [...codes].sort((a, b) => { - // First sort by disabled status - if (a.disabled && !b.disabled) return 1; - if (!a.disabled && b.disabled) return -1; - // Then sort by creation date (newest first) - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }); - }; - - const fetchCodes = async () => { - try { - setLoading(true); - setError(null); - - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.getInviteCodes`; - console.log("Fetching from:", url); - - const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), - "Content-Type": "application/json", - }; - - const params = new URLSearchParams({ - sort: "recent", - limit: "100", - }); - - const response = await fetch(`${url}?${params}`, { headers }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Error response:", errorText); - resetState(); - throw new Error( - `Failed to fetch codes: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - setCodes(data.codes || []); - } catch (err) { - console.error("Fetch error:", err); - resetState(); - setError(err instanceof Error ? err.message : "Failed to fetch codes"); - } finally { - setLoading(false); - } - }; - - const createCode = async () => { - try { - setLoading(true); - setError(null); - - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.server.createInviteCode`; - - const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), - "Content-Type": "application/json", - }; - - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - useCount: 1, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Error response:", errorText); - throw new Error( - `Failed to create code: ${response.status} ${response.statusText}`, - ); - } - - await fetchCodes(); - } catch (err) { - console.error("Create error:", err); - setError(err instanceof Error ? err.message : "Failed to create code"); - } finally { - setLoading(false); - } - }; - - const disableCode = async (code: string) => { - try { - setLoading(true); - setError(null); - - const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.disableInviteCodes`; - - const headers = { - Authorization: getAuthHeader(Settings.getAdminPassword()), - "Content-Type": "application/json", - }; - - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - codes: [code], - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Error response:", errorText); - throw new Error( - `Failed to disable code: ${response.status} ${response.statusText}`, - ); - } - - await fetchCodes(); - } catch (err) { - console.error("Disable error:", err); - setError(err instanceof Error ? err.message : "Failed to disable code"); - } finally { - setLoading(false); - } - }; - - const saveSettings = () => { - resetState(); - Settings.saveSettings(newUrl, newPassword); - setShowSettings(false); - fetchCodes(); - }; - - useEffect(() => { - return () => { - resetState(); - }; - }, []); - - useEffect(() => { - if (typeof window !== "undefined") { - fetchCodes(); - } - }, []); - - if (typeof window === "undefined") { - return
; - } - - return ( -
-
-
-
-

Invite Codes

-
- - -
-
- - {error && ( -
- - - -
-

Error!

-
{error}
-
- -
- )} - - {loading ? ( -
- -
- ) : ( -
- {sortCodes(codes).map((code, index) => ( -
-
-
-
- {code.code} -
-
- {code.disabled ? ( - Disabled - ) : ( - - )} -
-
-
- {!code.disabled && ( -
- Uses: {code.uses.length} / {code.available} -
- )} -
- Created: {new Date(code.createdAt).toLocaleString()} -
- {code.uses.length > 0 && ( -
- {code.uses.length}{" "} - {code.uses.length === 1 ? "use" : "uses"} -
- )} -
- {code.uses.length > 0 && ( -
- Used by:{" "} - {code.uses.map((use) => use.usedBy).join(", ")} -
- )} -
-
- ))} - {!loading && codes.length === 0 && ( -
-
- No invite codes found -
-
- )} -
- )} -
-
- - {/* Settings Modal */} - -
-

PDS Settings

-
- - setNewUrl(e.target.value)} - className="input input-bordered w-full" - placeholder="https://bsky.atri.dad" - /> -
-
- - setNewPassword(e.target.value)} - className="input input-bordered w-full" - /> -
-
- - - -
-
-
- -
-
-
- ); -} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/TabView.tsx b/src/components/TabView.tsx new file mode 100644 index 0000000..6526a5f --- /dev/null +++ b/src/components/TabView.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import InviteCodes from "./features/invites/InviteCodes"; + +const TABS = { + invites: { + component: InviteCodes, + label: "Invite Codes", + }, +} as const; + +type TabKey = keyof typeof TABS; + +export default function TabView() { + const [activeTab, setActiveTab] = useState("invites"); + const ActiveComponent = TABS[activeTab].component; + + return ( +
+
+
+
+ {Object.entries(TABS).map(([key, { label }]) => ( + + ))} +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/Welcome.astro b/src/components/Welcome.astro deleted file mode 100644 index 6b7b9c7..0000000 --- a/src/components/Welcome.astro +++ /dev/null @@ -1,209 +0,0 @@ ---- -import astroLogo from '../assets/astro.svg'; -import background from '../assets/background.svg'; ---- - - - - diff --git a/src/components/auth/AuthWrapper.tsx b/src/components/auth/AuthWrapper.tsx new file mode 100644 index 0000000..b5a1a49 --- /dev/null +++ b/src/components/auth/AuthWrapper.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { Settings } from "../../lib/settings"; +import NavBar from "../navigation/NavBar"; +import LoginForm from "./LoginForm"; +import { LogoutContext } from "../../lib/LogoutContext"; +import { RefreshContext } from "../../lib/RefreshContext"; +import TabView from "../TabView"; + +export default function AuthWrapper() { + const [isConfigured, setIsConfigured] = useState( + Boolean(Settings.getServiceUrl() && Settings.getAdminPassword()), + ); + const [lastUpdate, setLastUpdate] = useState(Date.now()); + + const handleLogout = () => { + Settings.clearSettings(); + setIsConfigured(false); + }; + + const refresh = () => { + setLastUpdate(Date.now()); + }; + + if (!isConfigured) { + return setIsConfigured(true)} />; + } + + return ( + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..1502749 --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Settings } from "../../lib/settings"; + +interface LoginFormProps { + onLogin: () => void; +} + +export default function LoginForm({ onLogin }: LoginFormProps) { + const [serviceUrl, setServiceUrl] = useState(""); + const [adminPassword, setAdminPassword] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + Settings.saveSettings(serviceUrl, adminPassword); + onLogin(); + }; + + return ( +
+
+
+

+ BlueSky PDS Manager +

+

+ Enter your PDS credentials to get started +

+
+
+ + setServiceUrl(e.target.value)} + placeholder="https://bsky.web.site" + required + className="input input-bordered w-full" + /> +
+
+ + setAdminPassword(e.target.value)} + required + className="input input-bordered w-full" + /> +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/features/invites/InviteCodes.tsx b/src/components/features/invites/InviteCodes.tsx new file mode 100644 index 0000000..ea4583e --- /dev/null +++ b/src/components/features/invites/InviteCodes.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect } from "react"; +import { Settings } from "../../../lib/settings"; +import { useRefresh } from "../../../lib/RefreshContext"; + +interface InviteCodeUse { + usedBy: string; + usedAt: string; +} + +interface InviteCode { + code: string; + available: number; + disabled: boolean; + forAccount: string; + createdBy: string; + createdAt: string; + uses: InviteCodeUse[]; +} + +function getAuthHeader(adminPassword: string) { + const authString = `admin:${adminPassword}`; + const base64Auth = btoa(authString); + return `Basic ${base64Auth}`; +} + +export default function InviteCodes() { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { lastUpdate } = useRefresh(); + + const resetState = () => { + setCodes([]); + setError(null); + setLoading(false); + }; + + const sortCodes = (codes: InviteCode[]) => { + return [...codes].sort((a, b) => { + if (a.disabled && !b.disabled) return 1; + if (!a.disabled && b.disabled) return -1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }; + + const fetchCodes = async () => { + try { + setLoading(true); + setError(null); + + const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.getInviteCodes`; + const headers = { + Authorization: getAuthHeader(Settings.getAdminPassword()), + "Content-Type": "application/json", + }; + + const params = new URLSearchParams({ + sort: "recent", + limit: "100", + }); + + const response = await fetch(`${url}?${params}`, { headers }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + resetState(); + throw new Error( + `Failed to fetch codes: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + setCodes(data.codes || []); + } catch (err) { + console.error("Fetch error:", err); + resetState(); + setError(err instanceof Error ? err.message : "Failed to fetch codes"); + } finally { + setLoading(false); + } + }; + + const createCode = async () => { + try { + setLoading(true); + setError(null); + + const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.server.createInviteCode`; + const headers = { + Authorization: getAuthHeader(Settings.getAdminPassword()), + "Content-Type": "application/json", + }; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + useCount: 1, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + throw new Error( + `Failed to create code: ${response.status} ${response.statusText}`, + ); + } + + await fetchCodes(); + } catch (err) { + console.error("Create error:", err); + setError(err instanceof Error ? err.message : "Failed to create code"); + } finally { + setLoading(false); + } + }; + + const disableCode = async (code: string) => { + try { + setLoading(true); + setError(null); + + const url = `${Settings.getServiceUrl()}/xrpc/com.atproto.admin.disableInviteCodes`; + const headers = { + Authorization: getAuthHeader(Settings.getAdminPassword()), + "Content-Type": "application/json", + }; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + codes: [code], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + throw new Error( + `Failed to disable code: ${response.status} ${response.statusText}`, + ); + } + + await fetchCodes(); + } catch (err) { + console.error("Disable error:", err); + setError(err instanceof Error ? err.message : "Failed to disable code"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + return () => { + resetState(); + }; + }, []); + + useEffect(() => { + if (typeof window !== "undefined") { + fetchCodes(); + } + }, [lastUpdate]); + + if (typeof window === "undefined") { + return
; + } + + return ( +
+
+
+

Invite Codes

+ +
+ + {error && ( +
+ + + +
+

Error!

+
{error}
+
+ +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {sortCodes(codes).map((code, index) => ( +
+
+
+
+ {code.code} +
+
+ {code.disabled ? ( + Disabled + ) : ( + + )} +
+
+
+ {!code.disabled && ( +
+ Uses: {code.uses.length} / {code.available} +
+ )} +
+ Created: {new Date(code.createdAt).toLocaleString()} +
+ {code.uses.length > 0 && ( +
+ {code.uses.length}{" "} + {code.uses.length === 1 ? "use" : "uses"} +
+ )} +
+ {code.uses.length > 0 && ( +
+ Used by:{" "} + {code.uses.map((use) => use.usedBy).join(", ")} +
+ )} +
+
+ ))} + {!loading && codes.length === 0 && ( +
+
+ No invite codes found +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/navigation/NavBar.tsx b/src/components/navigation/NavBar.tsx new file mode 100644 index 0000000..06ef4a1 --- /dev/null +++ b/src/components/navigation/NavBar.tsx @@ -0,0 +1,119 @@ +import { useLogout } from "../../lib/LogoutContext"; +import { useRefresh } from "../../lib/RefreshContext"; +import { useState } from "react"; +import { Settings } from "../../lib/settings"; + +export default function NavBar() { + const logout = useLogout(); + const { refresh } = useRefresh(); + const [showSettings, setShowSettings] = useState(false); + const [newUrl, setNewUrl] = useState(Settings.getServiceUrl()); + const [newPassword, setNewPassword] = useState(Settings.getAdminPassword()); + + const saveSettings = () => { + Settings.saveSettings(newUrl, newPassword); + setShowSettings(false); + refresh(); + }; + + return ( + <> +
+
+
+ BlueSky PDS Manager +
+
+ {/* Regular buttons for larger screens */} +
+ + +
+ + {/* Dropdown for mobile */} +
+ +
    +
  • + +
  • +
  • + +
  • +
+
+
+
+
+ + {/* Settings Modal */} + +
+

PDS Settings

+
+ + setNewUrl(e.target.value)} + className="input input-bordered w-full" + placeholder="https://bsky.atri.dad" + /> +
+
+ + setNewPassword(e.target.value)} + className="input input-bordered w-full" + /> +
+
+ + +
+
+
+ +
+
+ + ); +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index ef588b7..03abd5d 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,22 +1,16 @@ - + - + PDS Manager - + - - diff --git a/src/lib/LogoutContext.tsx b/src/lib/LogoutContext.tsx new file mode 100644 index 0000000..1f76cf5 --- /dev/null +++ b/src/lib/LogoutContext.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export const LogoutContext = React.createContext<(() => void) | undefined>( + undefined, +); + +export function useLogout() { + const context = React.useContext(LogoutContext); + if (!context) { + throw new Error("useLogout must be used within a LogoutProvider"); + } + return context; +} diff --git a/src/lib/RefreshContext.tsx b/src/lib/RefreshContext.tsx new file mode 100644 index 0000000..5dfbec3 --- /dev/null +++ b/src/lib/RefreshContext.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +interface RefreshContextType { + refresh: () => void; + lastUpdate: number; +} + +export const RefreshContext = React.createContext({ + refresh: () => {}, + lastUpdate: Date.now(), +}); + +export function useRefresh() { + return React.useContext(RefreshContext); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index a892b40..ade3327 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,8 +1,10 @@ --- import Layout from "../layouts/Layout.astro"; -import App from "../components/App"; +import AuthWrapper from "../components/auth/AuthWrapper"; --- - +
+ +