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
-
-
-
-
-
- );
- }
-
- 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 && (
-
- )}
-
- {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 */}
-
-
- );
-}
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
+
+
+
+
+
+ );
+}
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 && (
+
+ )}
+
+ {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 */}
+
+ >
+ );
+}
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";
---
-
+
+
+