beatufitull front end for ozone modration ,, wit catpucoin and ebergarden !
ozone moderation
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

add app catword suport !! yayy

+351 -46
+4
.env.example
··· 1 + PUBLIC_LABELER_DID="did:plc:example" 2 + PUBLIC_LABELER_URL="https://..." 3 + PUBLIC_PDS_URL="https://..." 4 + PUBLIC_AUTHENTICATION="oauth" # either "oauth" or "password"
+4 -4
flake.nix
··· 20 20 default = self.packages.${pkgs.stdenv.hostPlatform.system}.meowzone; 21 21 }); 22 22 23 - # devShells = forAllSystems (pkgs: { 24 - # default = pkgs.callPackage ./nix/shell.nix { }; 25 - # }); 23 + devShells = forAllSystems (pkgs: { 24 + default = pkgs.callPackage ./nix/shell.nix { }; 25 + }); 26 26 27 27 modules.default = ./nix/module.nix; 28 28 29 29 overlays.default = final: _: { meowzone = final.callPackage ./nix/default.nix { }; }; 30 30 }; 31 - } 31 + }
+3 -3
nix/default.nix
··· 1 1 { 2 2 fetchPnpmDeps, 3 - nodejs, 3 + nodejs_22, 4 4 pnpm, 5 5 pnpmConfigHook, 6 6 stdenv, 7 - lib 7 + lib, 8 8 }: 9 9 let 10 10 pkg = builtins.fromJSON (builtins.readFile ../package.json); ··· 16 16 src = ../.; 17 17 18 18 nativeBuildInputs = [ 19 - nodejs 19 + nodejs_22 20 20 pnpmConfigHook 21 21 pnpm 22 22 ];
+17 -1
nix/module.nix
··· 1 - { config, lib, pkgs, ... }: 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 2 7 3 8 with lib; 4 9 ··· 39 44 example = "https://ozone.example.com"; 40 45 }; 41 46 47 + authentication = mkOption { 48 + # either oauth or password 49 + type = types.enum [ 50 + "oauth" 51 + "password" 52 + ]; 53 + default = "oauth"; 54 + description = "Authentication method to use"; 55 + }; 56 + 42 57 package = mkOption { 43 58 type = types.package; 44 59 default = pkgs.callPackage ./default.nix { }; ··· 56 71 PUBLIC_LABELER_DID = cfg.serverDid; 57 72 PUBLIC_LABELER_URL = cfg.labelerUrl; 58 73 PUBLIC_PDS_URL = cfg.pdsUrl; 74 + PUBLIC_AUTHENTICATION = cfg.authentication; 59 75 PORT = toString cfg.port; 60 76 HOST = cfg.host; 61 77 NODE_ENV = "production";
+24
nix/shell.nix
··· 1 + { 2 + mkShell, 3 + callPackage, 4 + pkgs, 5 + stdenv, 6 + ... 7 + }: 8 + let 9 + defaultPackage = callPackage ./default.nix { }; 10 + in 11 + mkShell { 12 + inputsFrom = [ defaultPackage ]; 13 + 14 + packages = with pkgs; [ 15 + gnumake 16 + 17 + pkg-config 18 + 19 + vips 20 + python3 21 + gcc 22 + stdenv.cc.cc.lib 23 + ]; 24 + }
+1
src/components/Header.svelte
··· 11 11 if ($session) { 12 12 await $session.session.session.signOut(); 13 13 } 14 + localStorage.removeItem('meowzone-password-session'); 14 15 session.set(null); 15 16 window.location.href = '/login'; 16 17 }
+1
src/components/Sidebar.svelte
··· 21 21 if ($session) { 22 22 await $session.session.session.signOut(); 23 23 } 24 + localStorage.removeItem('meowzone-password-session'); 24 25 session.set(null); 25 26 window.location.href = '/login'; 26 27 }
+7
src/lib/api/ozone.ts
··· 28 28 throw new Error('Missing labeler URL. Set PUBLIC_LABELER_URL or pass labelerUrl.') 29 29 } 30 30 return labelerUrl 31 + } 32 + export function getAuthenticationMethod() { 33 + const authMethod = env.PUBLIC_AUTHENTICATION || 'oauth' 34 + if (authMethod !== 'oauth' && authMethod !== 'password') { 35 + throw new Error('Invalid authentication method. Set PUBLIC_AUTHENTICATION to either "oauth" or "password".') 36 + } 37 + return authMethod 31 38 }
+6 -6
src/lib/stores/auth.ts
··· 1 1 import { writable, type Writable } from 'svelte/store' 2 - import type { OAuthSession } from '@atproto/oauth-client-browser' 3 2 import type { Agent } from '@atproto/api' 4 3 4 + export interface AuthSession { 5 + signOut: () => Promise<void>; 6 + } 7 + 5 8 export interface AuthStore { 6 9 session: { 7 - session: OAuthSession; 8 - state?: never; 9 - } | { 10 - session: OAuthSession; 11 - state: string | null; 10 + session: AuthSession; 11 + state?: string | null; 12 12 }; 13 13 agent: Agent; 14 14 metadataPath: string;
+56 -24
src/routes/+layout.svelte
··· 2 2 import './layout.css'; 3 3 import favicon from '$lib/assets/favicon.svg'; 4 4 import { session } from '$lib/stores/auth'; 5 - import { page } from '$app/stores'; 6 5 import { onMount } from 'svelte'; 7 6 import { initializeAuth } from '$lib/oauth.client'; 8 7 import { LoaderCircle } from 'lucide-svelte'; 9 - import { Agent } from '@atproto/api'; 10 - import { getLabelerDid } from '$lib/api/ozone'; 8 + import { Agent, AtpAgent, type AtpSessionData } from '@atproto/api'; 9 + import { getAuthenticationMethod, getLabelerDid, getPdsUrl } from '$lib/api/ozone'; 11 10 import { createQuery, QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; 12 11 import LabelerSetup from '../components/LabelerSetup.svelte'; 13 12 import { moderationConfigQueryOptions } from '$lib/queries/moderation'; 14 13 import Sidebar from '$components/Sidebar.svelte'; 15 14 import Header from '$components/Header.svelte'; 15 + import { page } from '$app/state'; 16 16 17 17 let { children } = $props(); 18 + let currentPath = $derived(page.url.pathname); 18 19 let isInitialized = $state(false); 19 20 let redirecting = $state(false); 21 + const authMethod = getAuthenticationMethod(); 22 + const PASSWORD_SESSION_KEY = 'meowzone-password-session'; 20 23 21 24 onMount(async () => { 22 25 // Try to initialize/restore session first 23 26 try { 24 - const params = new URLSearchParams(location.hash.slice(1)); 27 + if (authMethod === 'password') { 28 + const rawSession = localStorage.getItem(PASSWORD_SESSION_KEY); 29 + if (rawSession) { 30 + try { 31 + const storedSession = JSON.parse(rawSession) as AtpSessionData; 32 + const baseAgent = new AtpAgent({ service: getPdsUrl() }); 33 + await baseAgent.resumeSession(storedSession); 34 + session.set({ 35 + session: { 36 + session: { 37 + signOut: async () => { 38 + await baseAgent.logout(); 39 + localStorage.removeItem(PASSWORD_SESSION_KEY); 40 + } 41 + } 42 + }, 43 + agent: baseAgent.withProxy('atproto_labeler', getLabelerDid()), 44 + metadataPath: '/client-metadata.json' 45 + }); 46 + } catch { 47 + localStorage.removeItem(PASSWORD_SESSION_KEY); 48 + } 49 + } 50 + } else { 51 + const params = new URLSearchParams(location.hash.slice(1)); 25 52 26 - console.log('OAuth init result (layout):', params); 27 - const existingSession = await initializeAuth(); 28 - if (existingSession) { 29 - const metadataPath = 30 - typeof window !== 'undefined' 31 - ? sessionStorage.getItem('oauth-metadata-path') || '/client-metadata.json' 32 - : '/client-metadata.json'; 33 - session.set({ 34 - session: { session: existingSession }, 35 - agent: new Agent(existingSession).withProxy('atproto_labeler', getLabelerDid()), 36 - metadataPath 37 - }); 53 + console.log('OAuth init result (layout):', params); 54 + const existingSession = await initializeAuth(); 55 + if (existingSession) { 56 + const metadataPath = 57 + typeof window !== 'undefined' 58 + ? sessionStorage.getItem('oauth-metadata-path') || '/client-metadata.json' 59 + : '/client-metadata.json'; 60 + session.set({ 61 + session: { session: existingSession }, 62 + agent: new Agent(existingSession).withProxy('atproto_labeler', getLabelerDid()), 63 + metadataPath 64 + }); 65 + } 38 66 } 39 67 } catch (err) { 40 68 console.error('Failed to initialize auth:', err); 41 69 } 42 70 43 71 // Check if we're on a protected page 44 - const currentPath = $page.url.pathname; 72 + const currentPath = page.url.pathname; 45 73 const isLoginPage = currentPath === '/login'; 46 74 const isCallbackPage = currentPath.includes('callback'); 47 75 const isPublicPage = isLoginPage || isCallbackPage; ··· 74 102 {:else if !labelerConfig.data?.plcLabeler || !labelerConfig.data?.moderatorRecord} 75 103 <LabelerSetup /> 76 104 {:else if !redirecting} 77 - <div class="w-full bg-ctp-base md:flex"> 78 - <div class="hidden md:block"> 79 - <Sidebar /> 80 - </div> 81 - <div class="px-4 pt-4 md:hidden"> 82 - <Header /> 105 + {#if currentPath !== '/login'} 106 + <div class="w-full bg-ctp-base md:flex"> 107 + <div class="hidden md:block"> 108 + <Sidebar /> 109 + </div> 110 + <div class="px-4 pt-4 md:hidden"> 111 + <Header /> 112 + </div> 113 + {@render children()} 83 114 </div> 115 + {:else} 84 116 {@render children()} 85 - </div> 117 + {/if} 86 118 {:else} 87 119 <div class="flex min-h-screen items-center justify-center"> 88 120 <div class="text-center">
+170
src/routes/events/+page.svelte
··· 1 + <script lang="ts"> 2 + import { session, type AuthStore } from '$lib/stores/auth'; 3 + import { 4 + LoaderCircle, 5 + ExternalLink, 6 + TriangleAlert, 7 + CheckIcon, 8 + CircleCheck, 9 + Scale 10 + } from 'lucide-svelte'; 11 + import { createInfiniteQuery, createQuery, QueryClient } from '@tanstack/svelte-query'; 12 + import { AtUri, ComAtprotoRepoStrongRef } from '@atproto/api'; 13 + import Header from '../components/Header.svelte'; 14 + import Modal from '../components/Modal.svelte'; 15 + import UserModal from '../components/view/User.svelte'; 16 + import Button from '../components/ui/Button.svelte'; 17 + import type { Report } from '$lib/types'; 18 + import { isRepoRef } from '@atproto/api/dist/client/types/com/atproto/admin/defs'; 19 + import { formatDate } from '$lib/time'; 20 + import Post from '$components/view/Post.svelte'; 21 + import { page } from '$app/state'; 22 + import Sidebar from '$components/Sidebar.svelte'; 23 + 24 + let selectedDid: string | null = null; 25 + let selectedPostUri: AtUri | null = null; 26 + function getCollectionName(report: Report) { 27 + try { 28 + return report.subject.handle ? 'Post' : 'unknown'; 29 + } catch (e) { 30 + console.error('Invalid report:', report); 31 + return 'unknown'; 32 + } 33 + } 34 + // switch to infinite query so we can page with the cursor returned by the API 35 + const reportsQuery = createInfiniteQuery(() => ({ 36 + queryKey: ['reports'], 37 + queryFn: async ({ pageParam }) => { 38 + if (!$session) { 39 + throw new Error('No active session'); 40 + } 41 + const statuses = await $session.agent.tools.ozone.moderation.queryStatuses({ 42 + limit: 10, 43 + sortField: 'lastReportedAt', 44 + sortDirection: 'desc', 45 + // pageParam will be the cursor string if present 46 + cursor: pageParam == '' ? undefined : pageParam 47 + }); 48 + 49 + return statuses.data; 50 + }, 51 + initialPageParam: '', 52 + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 53 + retry(failureCount, error) { 54 + console.log('Fetch reports error:', error); 55 + return failureCount < 3; 56 + }, 57 + enabled: !!$session 58 + })); 59 + 60 + function infiniteScroll(node: HTMLElement) { 61 + const observer = new IntersectionObserver( 62 + (entries) => { 63 + if ( 64 + entries[0].isIntersecting && 65 + reportsQuery.hasNextPage && 66 + !reportsQuery.isFetchingNextPage 67 + ) { 68 + reportsQuery.fetchNextPage(); 69 + } 70 + }, 71 + { 72 + rootMargin: '200px' 73 + } 74 + ); 75 + 76 + observer.observe(node); 77 + 78 + return { 79 + destroy() { 80 + observer.disconnect(); 81 + } 82 + }; 83 + } 84 + </script> 85 + 86 + <div class="min-h-screen w-full bg-ctp-base p-4"> 87 + {#if selectedDid} 88 + {#key selectedDid} 89 + <UserModal isOpen={true} did={selectedDid} onClose={() => (selectedDid = null)} /> 90 + {/key} 91 + {:else if selectedPostUri} 92 + {#key selectedPostUri} 93 + <Post isOpen={true} uri={selectedPostUri} onClose={() => (selectedPostUri = null)} /> 94 + {/key} 95 + {/if} 96 + <div class="mx-auto max-w-2xl"> 97 + {#if reportsQuery.isLoading} 98 + <div class="flex min-h-screen items-center justify-center"> 99 + <div class="text-center"> 100 + <LoaderCircle class="mx-auto mb-4 h-8 w-8 animate-spin text-ctp-subtext0" /> 101 + </div> 102 + </div> 103 + {:else if reportsQuery.isError} 104 + <div class="rounded-lg bg-ctp-red/20 p-4 text-ctp-red shadow-lg"> 105 + <h2 class="font-semibold">Error</h2> 106 + <p>{reportsQuery.error?.message}</p> 107 + </div> 108 + {:else if reportsQuery.data} 109 + <h2 class="pb-5 text-xl font-medium text-ctp-text">Recent Reports</h2> 110 + <ul class="space-y-4 text-ctp-text"> 111 + {#each reportsQuery.data.pages as page} 112 + {#each page.subjectStatuses as report} 113 + {@const subject = report.subject} 114 + <li class="rounded-lg border border-ctp-surface1 bg-ctp-surface0 p-4"> 115 + <div class="flex items-center justify-between"> 116 + <div class="flex items-center gap-2"> 117 + {#if report.appealed} 118 + <Scale class="size-5 text-ctp-yellow" /> 119 + {:else if report.reviewState == 'tools.ozone.moderation.defs#reviewClosed'} 120 + <CircleCheck class="size-5 text-ctp-green" /> 121 + {:else} 122 + <TriangleAlert class="size-5 text-ctp-yellow" /> 123 + {/if} 124 + <p> 125 + {#if ComAtprotoRepoStrongRef.isMain(subject)} 126 + <button 127 + on:click={() => (selectedPostUri = new AtUri(subject.uri))} 128 + class="inline-flex cursor-pointer items-center px-0 py-0 text-ctp-blue hover:underline" 129 + > 130 + Post 131 + <ExternalLink size={15} class="ml-1" /> 132 + </button> 133 + created by 134 + <button 135 + on:click={() => (selectedDid = new AtUri(subject.uri).host)} 136 + class="inline-flex cursor-pointer items-center px-0 py-0 text-ctp-blue hover:underline" 137 + > 138 + @{report.subjectRepoHandle} 139 + <ExternalLink size={15} class="ml-1" /> 140 + </button> 141 + {:else if isRepoRef(subject)} 142 + <button 143 + on:click={() => (selectedDid = subject.did)} 144 + class="inline-flex cursor-pointer items-center px-0 py-0 text-ctp-blue hover:underline" 145 + > 146 + @{report.subjectRepoHandle} 147 + <ExternalLink size={15} class="ml-1" /> 148 + </button> 149 + {/if} 150 + </p> 151 + </div> 152 + <p class="text-sm text-ctp-subtext0"> 153 + {formatDate(report.lastReportedAt ?? report.createdAt)} 154 + </p> 155 + </div> 156 + </li> 157 + {/each} 158 + {/each} 159 + {#if reportsQuery.hasNextPage} 160 + <div use:infiniteScroll class="h-10"></div> 161 + {/if} 162 + {#if reportsQuery.isFetchingNextPage} 163 + <div class="flex justify-center py-4"> 164 + <LoaderCircle class="h-6 w-6 animate-spin text-ctp-subtext0" /> 165 + </div> 166 + {/if} 167 + </ul> 168 + {/if} 169 + </div> 170 + </div>
+58 -8
src/routes/login/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { error, isLoading } from '$lib/stores/auth'; 3 3 import { authorize } from '$lib/oauth.client'; 4 - import { getLabelerDid, getPdsUrl } from '$lib/api/ozone'; 5 - import { Agent } from '@atproto/api'; 4 + import { getAuthenticationMethod, getLabelerDid, getPdsUrl } from '$lib/api/ozone'; 5 + import { Agent, AtpAgent } from '@atproto/api'; 6 6 import Button from '../../components/ui/Button.svelte'; 7 7 import Input from '../../components/ui/Input.svelte'; 8 8 import { CatIcon } from 'lucide-svelte'; 9 9 10 - let handleInput = ''; 10 + let handleInput = $state(''); 11 + let passwordInput = $state(''); 12 + const authMethod = getAuthenticationMethod(); 13 + const PASSWORD_SESSION_KEY = 'meowzone-password-session'; 11 14 12 15 async function handleLogin() { 13 16 if (!handleInput.trim()) { ··· 15 18 return; 16 19 } 17 20 21 + if (authMethod === 'password' && !passwordInput.trim()) { 22 + error.set('Please enter your app password'); 23 + return; 24 + } 25 + 18 26 isLoading.set(true); 19 27 error.set(null); 20 28 ··· 31 39 return; 32 40 } 33 41 34 - const authUrl = await authorize(did); 35 - // Redirect to authorization page 36 - window.location.href = authUrl.toString(); 42 + if (authMethod === 'password') { 43 + const agent = new AtpAgent({ service: getPdsUrl() }); 44 + await agent.login({ 45 + identifier: did, 46 + password: passwordInput 47 + }); 48 + 49 + if (!agent.session) { 50 + throw new Error('Failed to establish session'); 51 + } 52 + 53 + localStorage.setItem(PASSWORD_SESSION_KEY, JSON.stringify(agent.session)); 54 + window.location.href = '/'; 55 + } else { 56 + const authUrl = await authorize(did); 57 + // Redirect to authorization page 58 + window.location.href = authUrl.toString(); 59 + } 37 60 } catch (err) { 38 61 const message = err instanceof Error ? err.message : 'Authentication failed'; 39 62 error.set(message); ··· 44 67 function handleInputChange(e: Event) { 45 68 const target = e.target as HTMLInputElement; 46 69 handleInput = target.value; 70 + if ($error) error.set(null); 71 + } 72 + 73 + function handlePasswordInputChange(e: Event) { 74 + const target = e.target as HTMLInputElement; 75 + passwordInput = target.value; 47 76 if ($error) error.set(null); 48 77 } 49 78 ··· 76 105 /> 77 106 </div> 78 107 108 + {#if authMethod === 'password'} 109 + <div> 110 + <Input 111 + id="password" 112 + type="password" 113 + placeholder="app password" 114 + bind:value={passwordInput} 115 + on:input={handlePasswordInputChange} 116 + on:keypress={handleKeyPress} 117 + disabled={$isLoading} 118 + className="mt-1" 119 + /> 120 + </div> 121 + {/if} 122 + 79 123 {#if handleInput == 'coil-habdle.ebil.club'} 80 124 <p class="text-ctp-mauve">woaa ,, dat coil hablde !!</p> 81 125 {:else if handleInput == 'olaren.dev'} ··· 92 136 variant="primary" 93 137 fullWidth={true} 94 138 on:click={handleLogin} 95 - disabled={$isLoading || !handleInput.trim()} 96 - className={$isLoading || !handleInput.trim() ? 'bg-ctp-surface1' : ''} 139 + disabled={$isLoading || 140 + !handleInput.trim() || 141 + (authMethod === 'password' && !passwordInput.trim())} 142 + className={$isLoading || 143 + !handleInput.trim() || 144 + (authMethod === 'password' && !passwordInput.trim()) 145 + ? 'bg-ctp-surface1' 146 + : ''} 97 147 > 98 148 {$isLoading ? 'signing in...' : 'sign in'} 99 149 </Button>