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

Configure Feed

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

betear mobile client picker and tooltips !

+175 -70
+55 -19
src/components/ClientPicker.svelte
··· 4 4 import catskyIcon from '$lib/assets/catsky.png'; 5 5 import { onMount } from 'svelte'; 6 6 import PopupMenu from './ui/PopupMenu.svelte'; 7 + import { ChevronDown } from 'lucide-svelte'; 7 8 8 9 type Client = 'bluesky' | 'witchsky' | 'catsky'; 9 10 let lastUsed = $state<Client | null>(null); ··· 50 51 { name: 'witchsky', icon: witchsky, baseUrl: 'https://witchsky.app' }, 51 52 { name: 'catsky', icon: catsky, baseUrl: 'https://catsky.social' } 52 53 ] satisfies ClientInfo[]; 54 + 55 + // if md screen 56 + let isMobile = window.innerWidth < 768; 57 + window.addEventListener('resize', () => { 58 + isMobile = window.innerWidth < 768; 59 + }); 53 60 </script> 54 61 55 62 {#snippet bsky()} ··· 68 75 /> 69 76 {/snippet} 70 77 71 - {#snippet client(client: ClientInfo, onClick = () => {})} 72 - <a 73 - class="group block rounded-sm p-1 hover:bg-ctp-surface0" 74 - onclick={() => { 75 - handleClientClick(client.name); 76 - onClick(); 77 - }} 78 - href={`${client.baseUrl}${path}`} 79 - target="_blank" 80 - rel="noopener noreferrer" 81 - > 82 - {@render client.icon()} 83 - </a> 78 + {#snippet client(client: ClientInfo, isLink: boolean, onClick = () => {})} 79 + {#if !isLink} 80 + <div class="block rounded-sm p-1"> 81 + {@render client.icon()} 82 + </div> 83 + {:else} 84 + <a 85 + class="group block rounded-sm p-1 hover:bg-ctp-surface0" 86 + onclick={() => { 87 + handleClientClick(client.name); 88 + onClick(); 89 + }} 90 + href={`${client.baseUrl}${path}`} 91 + target="_blank" 92 + rel="noopener noreferrer" 93 + > 94 + {@render client.icon()} 95 + </a> 96 + {/if} 84 97 {/snippet} 85 98 86 99 <div 87 100 class="relative ml-auto" 88 101 role="region" 89 - onmouseenter={() => (isOpen = true)} 90 - onmouseleave={() => (isOpen = false)} 102 + onmouseenter={() => { 103 + if (!isMobile) { 104 + isOpen = true; 105 + } 106 + }} 107 + onmouseleave={() => { 108 + if (!isMobile) { 109 + isOpen = false; 110 + } 111 + }} 91 112 > 92 113 <PopupMenu 93 - enableOverlay={false} 114 + enableOverlay={isMobile} 115 + onClose={() => (isOpen = false)} 94 116 open={isOpen} 95 117 menuClass="absolute top-full right-0 z-50 rounded-md border border-ctp-surface1 bg-ctp-base p-1 shadow-lg" 96 118 > 97 119 <svelte:fragment slot="trigger"> 98 - {@render client(clients.find((c) => c.name === lastUsed) ?? clients[0])} 120 + <button 121 + onclick={() => { 122 + if (isMobile) { 123 + isOpen = !isOpen; 124 + } 125 + }} 126 + class="group flex items-center rounded-sm pr-1 hover:bg-ctp-surface0 md:pr-0" 127 + > 128 + {@render client(clients.find((c) => c.name === lastUsed) ?? clients[0], !isMobile)} 129 + <ChevronDown 130 + class="size-4 text-ctp-text transition-transform duration-300 md:hidden {isOpen 131 + ? 'rotate-180' 132 + : 'rotate-0'}" 133 + /> 134 + </button> 99 135 </svelte:fragment> 100 136 <svelte:fragment slot="content"> 101 137 <div class="flex flex-col gap-1" role="menu"> 102 - {#each clients as clientData} 103 - {@render client(clientData, () => (isOpen = false))} 138 + {#each clients as clientData (clientData.name)} 139 + {@render client(clientData, true, () => (isOpen = false))} 104 140 {/each} 105 141 </div> 106 142 </svelte:fragment>
+10 -1
src/components/ui/PopupMenu.svelte
··· 1 1 <script lang="ts"> 2 + import { scale } from 'svelte/transition'; 3 + 2 4 let { 3 5 open = false, 6 + onClose = () => {}, 4 7 enableOverlay = true, 5 8 wrapperClass = 'relative', 6 9 menuClass = '', 7 10 overlayClass = 'fixed inset-0 z-40' 8 11 } = $props<{ 9 12 open?: boolean; 13 + onClose?: () => void; 10 14 enableOverlay?: boolean; 11 15 wrapperClass?: string; 12 16 menuClass?: string; ··· 19 23 20 24 function close() { 21 25 open = false; 26 + onClose(); 22 27 } 23 28 </script> 24 29 ··· 30 35 <slot name="trigger" {open} {toggle} {close}></slot> 31 36 32 37 {#if open} 33 - <div class={menuClass}> 38 + <div 39 + class={`origin-top ${menuClass}`} 40 + in:scale={{ duration: 200 }} 41 + out:scale={{ duration: 150 }} 42 + > 34 43 <slot name="content" {close}></slot> 35 44 </div> 36 45 {/if}
+47
src/components/ui/Tooltip.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + import { scale } from 'svelte/transition'; 4 + 5 + let visiblity = $state(false); 6 + let { 7 + children, 8 + text, 9 + position = 'center' 10 + }: { 11 + children?: Snippet; 12 + text: string; 13 + position: 'center' | 'left' | 'right'; 14 + } = $props(); 15 + 16 + function setVisiblity(visible: boolean) { 17 + visiblity = visible; 18 + } 19 + 20 + const POSITION_CLASSES = { 21 + center: { origin: 'origin-top', position: 'left-1/2 -translate-x-1/2' }, 22 + left: { origin: 'origin-top-right', position: 'right-0' }, 23 + right: { origin: 'origin-top-left', position: 'left-0' } 24 + } satisfies Record<string, { origin: string; position: string }>; 25 + 26 + const { origin: tailwindOrigin, position: tailwindPosition } = $derived( 27 + POSITION_CLASSES[position ?? 'center'] 28 + ); 29 + </script> 30 + 31 + <div 32 + role="tooltip" 33 + class="relative" 34 + onmouseenter={() => setVisiblity(true)} 35 + onmouseleave={() => setVisiblity(false)} 36 + > 37 + {#if visiblity} 38 + <div 39 + class={`absolute z-50 mt-8 flex w-max max-w-xs items-center rounded-xl border border-ctp-surface1 bg-ctp-surface0 px-3 py-2 text-sm whitespace-normal text-ctp-text shadow ${tailwindOrigin} ${tailwindPosition}`} 40 + in:scale={{ duration: 300 }} 41 + out:scale={{ duration: 200 }} 42 + > 43 + {text} 44 + </div> 45 + {/if} 46 + {@render children?.()} 47 + </div>
+1 -1
src/components/view/Actions.svelte
··· 199 199 value={reason} 200 200 on:input={(event) => onReasonChange((event.currentTarget as HTMLInputElement).value)} 201 201 /> 202 - {#if isOpen} 202 + {#if isOpen && labelAction != 'acknowledge'} 203 203 <div> 204 204 <Checkbox 205 205 checked={resolveOnSubmit}
+14 -7
src/components/view/Events.svelte
··· 1 1 <script lang="ts"> 2 2 import { formatDate } from '$lib/time'; 3 + import { openPost, openUser } from '$lib/utils/nav'; 3 4 import { 4 5 AtUri, 5 6 ComAtprotoRepoStrongRef, 6 - type ToolsOzoneModerationDefs, 7 7 type ToolsOzoneModerationQueryEvents 8 8 } from '@atproto/api'; 9 9 import { isRepoRef } from '@atproto/api/dist/client/types/com/atproto/admin/defs'; ··· 18 18 InfiniteData 19 19 } from '@tanstack/svelte-query'; 20 20 import { 21 - CheckIcon, 22 21 CircleCheck, 23 22 CircleQuestionMark, 24 23 CircleUserRound, ··· 27 26 FlagIcon, 28 27 HammerIcon, 29 28 LoaderCircle, 30 - StampIcon, 31 - TagIcon, 32 - ToolboxIcon 29 + TagIcon 33 30 } from 'lucide-svelte'; 34 31 35 - let { eventsQuery, onUserClick, onPostClick, elevation, showInfo } = $props<{ 32 + let { 33 + eventsQuery, 34 + onUserClick = (did: string) => { 35 + openUser(did); 36 + }, 37 + onPostClick = (uri: AtUri) => { 38 + openPost(uri.toString()); 39 + }, 40 + elevation, 41 + showInfo 42 + } = $props<{ 36 43 eventsQuery: 37 44 | CreateQueryResult<ToolsOzoneModerationQueryEvents.OutputSchema, Error> 38 45 | CreateInfiniteQueryResult< ··· 81 88 <p class="text-sm text-ctp-subtext0">No moderation events found.</p> 82 89 {:else} 83 90 <div class="space-y-2"> 84 - {#each events as event} 91 + {#each events as event (event.id)} 85 92 <div 86 93 class={`rounded-lg border ${elevation == 'lower' ? 'border-ctp-surface1' : 'border-ctp-surface2'} p-3`} 87 94 >
+8 -1
src/components/view/User.svelte
··· 7 7 import { session } from '$lib/stores/auth'; 8 8 import catsky from '$lib/assets/catsky.png'; 9 9 import Bluesky from '$lib/assets/bluesky.svelte'; 10 + import Tooltip from '$components/ui/Tooltip.svelte'; 10 11 import Events from '$components/view/Events.svelte'; 11 12 import Actions from '$components/view/Actions.svelte'; 12 13 import EventsContainer from './EventsContainer.svelte'; ··· 120 121 size={50} 121 122 /> 122 123 <div> 123 - <h2 class="text-xl text-ctp-text">{userQuery.data.profile.displayName}</h2> 124 + {#if userQuery.data.profile.displayName} 125 + <Tooltip position="center" text={userQuery.data.profile.displayName}> 126 + <h2 class="max-w-40 cursor-help truncate text-xl text-ctp-text md:max-w-44"> 127 + {userQuery.data.profile.displayName} 128 + </h2> 129 + </Tooltip> 130 + {/if} 124 131 <p class="text-sm text-ctp-subtext0">@{userQuery.data.profile.handle}</p> 125 132 </div> 126 133 <ClientPicker path={`/profile/${actor}`} />
+37
src/lib/utils/nav.ts
··· 1 + import { goto } from "$app/navigation"; 2 + import { page } from "$app/state"; 3 + 4 + function buildUrlForView(view: 'user' | 'post' | null, value?: string) { 5 + const url = new URL(page.url); 6 + url.searchParams.delete('view'); 7 + url.searchParams.delete('did'); 8 + url.searchParams.delete('uri'); 9 + 10 + if (view === 'user' && value) { 11 + url.searchParams.set('view', 'user'); 12 + url.searchParams.set('did', value); 13 + } 14 + 15 + if (view === 'post' && value) { 16 + url.searchParams.set('view', 'post'); 17 + url.searchParams.set('uri', value); 18 + } 19 + 20 + return `${url.pathname}${url.search}${url.hash}`; 21 + } 22 + 23 + export function navigateView(view: 'user' | 'post' | null, value?: string) { 24 + void goto(buildUrlForView(view, value), { 25 + replaceState: true, 26 + keepFocus: true, 27 + noScroll: true 28 + }); 29 + } 30 + 31 + export function openUser(did: string) { 32 + navigateView('user', did); 33 + } 34 + 35 + export function openPost(uri: string) { 36 + navigateView('post', uri); 37 + }
+3 -41
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { session, type AuthStore } from '$lib/stores/auth'; 2 + import { session } from '$lib/stores/auth'; 3 3 import { 4 4 LoaderCircle, 5 5 ExternalLink, 6 6 TriangleAlert, 7 - CheckIcon, 8 7 CircleCheck, 9 8 Scale, 10 9 ShieldAlertIcon 11 10 } from 'lucide-svelte'; 12 - import { createInfiniteQuery, createQuery, QueryClient } from '@tanstack/svelte-query'; 11 + import { createInfiniteQuery } from '@tanstack/svelte-query'; 13 12 import { AtUri, ComAtprotoRepoStrongRef } from '@atproto/api'; 14 - import type { Report } from '$lib/types'; 15 13 import { isRepoRef } from '@atproto/api/dist/client/types/com/atproto/admin/defs'; 16 14 import { formatDate } from '$lib/time'; 17 - import { page } from '$app/state'; 18 - import { goto } from '$app/navigation'; 19 15 import AdvertisementOverlay from '$components/AdvertisementOverlay.svelte'; 20 16 import Tabs from '$components/ui/Tabs.svelte'; 21 17 import MikuHelp from '$components/MikuHelp.svelte'; 22 - 23 - function buildUrlForView(view: 'user' | 'post' | null, value?: string) { 24 - const url = new URL(page.url); 25 - url.searchParams.delete('view'); 26 - url.searchParams.delete('did'); 27 - url.searchParams.delete('uri'); 28 - 29 - if (view === 'user' && value) { 30 - url.searchParams.set('view', 'user'); 31 - url.searchParams.set('did', value); 32 - } 33 - 34 - if (view === 'post' && value) { 35 - url.searchParams.set('view', 'post'); 36 - url.searchParams.set('uri', value); 37 - } 38 - 39 - return `${url.pathname}${url.search}${url.hash}`; 40 - } 41 - 42 - function navigateView(view: 'user' | 'post' | null, value?: string) { 43 - void goto(buildUrlForView(view, value), { 44 - replaceState: true, 45 - keepFocus: true, 46 - noScroll: true 47 - }); 48 - } 49 - 50 - function openUser(did: string) { 51 - navigateView('user', did); 52 - } 53 - 54 - function openPost(uri: string) { 55 - navigateView('post', uri); 56 - } 18 + import { openPost, openUser } from '$lib/utils/nav'; 57 19 58 20 type State = 59 21 | 'tools.ozone.moderation.defs#reviewOpen'