Your calm window into the Atmosphere. morgen.blue
rss atproto
3
fork

Configure Feed

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

Merge pull request #5 from hfrdmnk/main-layout

feat(layout): window-layout shell + signed-in user in account dropdown

authored by

Dominik Hofer and committed by
GitHub
b5e248a0 03b70e2b

+344 -82
+1 -1
app/Http/Controllers/Auth/OAuthCallbackController.php
··· 38 38 39 39 Auth::login($user, remember: true); 40 40 41 - return to_route('dashboard'); 41 + return to_route('consume'); 42 42 } 43 43 }
+6
app/Http/Middleware/HandleInertiaRequests.php
··· 2 2 3 3 namespace App\Http\Middleware; 4 4 5 + use App\Services\PdsProfileService; 5 6 use Illuminate\Http\Request; 7 + use Inertia\Inertia; 6 8 use Inertia\Middleware; 7 9 8 10 class HandleInertiaRequests extends Middleware ··· 20 22 'auth' => [ 21 23 'user' => $request->user(), 22 24 'handle' => $request->session()->get('atproto.handle'), 25 + 'profile' => Inertia::defer(fn () => $request->user() 26 + ? app(PdsProfileService::class)->for($request->user()) 27 + : null 28 + ), 23 29 ], 24 30 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 25 31 ];
+46
app/Services/PdsProfileService.php
··· 1 + <?php 2 + 3 + namespace App\Services; 4 + 5 + use App\Models\User; 6 + use Illuminate\Support\Facades\Cache; 7 + use Revolution\Bluesky\Facades\Bluesky; 8 + use Throwable; 9 + 10 + class PdsProfileService 11 + { 12 + /** 13 + * @return array{avatar: ?string, displayName: ?string}|null 14 + */ 15 + public function for(User $user): ?array 16 + { 17 + return Cache::remember( 18 + "pds-profile:{$user->did}", 19 + now()->addHour(), 20 + fn () => $this->fetch($user->did), 21 + ); 22 + } 23 + 24 + /** 25 + * @return array{avatar: ?string, displayName: ?string}|null 26 + */ 27 + private function fetch(string $did): ?array 28 + { 29 + try { 30 + $profile = Bluesky::public()->getProfile(actor: $did)->json(); 31 + } catch (Throwable $e) { 32 + report($e); 33 + 34 + return null; 35 + } 36 + 37 + if (! is_array($profile)) { 38 + return null; 39 + } 40 + 41 + return [ 42 + 'avatar' => $profile['avatar'] ?? null, 43 + 'displayName' => $profile['displayName'] ?? null, 44 + ]; 45 + } 46 + }
+3
resources/css/app.css
··· 45 45 --color-sand-brown: oklch(0.9579 0.0301 69.05); 46 46 47 47 --color-brand: var(--color-atmosphere-blue); 48 + 49 + --text-2xs: 0.625rem; 50 + --text-2xs--line-height: calc(0.875 / 0.625); 48 51 } 49 52 50 53 @theme inline {
+5
resources/js/app.tsx
··· 6 6 import AuthGoldenLayout from '@/layouts/auth/auth-golden-layout'; 7 7 import AuthLayout from '@/layouts/auth-layout'; 8 8 import SettingsLayout from '@/layouts/settings/layout'; 9 + import WindowLayout from '@/layouts/window-layout'; 10 + 11 + const WINDOW_PAGES = new Set(['discover', 'consume', 'create']); 9 12 10 13 const appName = import.meta.env.VITE_APP_NAME || 'Morgenblau'; 11 14 ··· 21 24 return AuthLayout; 22 25 case name.startsWith('settings/'): 23 26 return [AppLayout, SettingsLayout]; 27 + case WINDOW_PAGES.has(name): 28 + return WindowLayout; 24 29 default: 25 30 return AppLayout; 26 31 }
+3 -3
resources/js/components/app-header.tsx
··· 38 38 import { UserMenuContent } from '@/components/user-menu-content'; 39 39 import { useCurrentUrl } from '@/hooks/use-current-url'; 40 40 import { cn, toUrl } from '@/lib/utils'; 41 - import { dashboard } from '@/routes'; 41 + import { consume } from '@/routes'; 42 42 import type { BreadcrumbItem, NavItem } from '@/types'; 43 43 44 44 type Props = { ··· 48 48 const mainNavItems: NavItem[] = [ 49 49 { 50 50 title: 'Dashboard', 51 - href: dashboard(), 51 + href: consume(), 52 52 icon: LayoutGridIcon, 53 53 }, 54 54 ]; ··· 156 156 </div> 157 157 158 158 <Link 159 - href={dashboard()} 159 + href={consume()} 160 160 prefetch 161 161 className="flex items-center space-x-2" 162 162 >
+3 -3
resources/js/components/app-sidebar.tsx
··· 17 17 SidebarMenuButton, 18 18 SidebarMenuItem, 19 19 } from '@/components/ui/sidebar'; 20 - import { dashboard } from '@/routes'; 20 + import { consume } from '@/routes'; 21 21 import type { NavItem } from '@/types'; 22 22 23 23 const mainNavItems: NavItem[] = [ 24 24 { 25 25 title: 'Dashboard', 26 - href: dashboard(), 26 + href: consume(), 27 27 icon: LayoutGridIcon, 28 28 }, 29 29 ]; ··· 49 49 <SidebarMenuItem> 50 50 <SidebarMenuButton 51 51 size="lg" 52 - render={<Link href={dashboard()} prefetch />} 52 + render={<Link href={consume()} prefetch />} 53 53 > 54 54 <AppLogo /> 55 55 </SidebarMenuButton>
+6 -2
resources/js/components/ui/dropdown-menu.tsx
··· 1 1 import * as React from "react" 2 2 import { Menu as MenuPrimitive } from "@base-ui/react/menu" 3 3 4 + import { LevelContext } from "@/lib/level-context" 4 5 import { cn } from "@/lib/utils" 5 6 import { HugeiconsIcon } from "@hugeicons/react" 6 7 import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons" ··· 23 24 side = "bottom", 24 25 sideOffset = 4, 25 26 className, 27 + children, 26 28 ...props 27 29 }: MenuPrimitive.Popup.Props & 28 30 Pick< ··· 40 42 > 41 43 <MenuPrimitive.Popup 42 44 data-slot="dropdown-menu-content" 43 - className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )} 45 + className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-gray-100 bg-popover p-1 text-popover-foreground shadow-sm duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 dark:border-gray-700", className )} 44 46 {...props} 45 - /> 47 + > 48 + <LevelContext.Provider value={2}>{children}</LevelContext.Provider> 49 + </MenuPrimitive.Popup> 46 50 </MenuPrimitive.Positioner> 47 51 </MenuPrimitive.Portal> 48 52 )
+1 -16
resources/js/components/user-info.tsx
··· 1 1 import { Avatar, AvatarFallback } from '@/components/ui/avatar'; 2 - 3 - function initialsFromHandle(handle: string | null, did: string): string { 4 - const source = handle ?? did.replace(/^did:[a-z]+:/, ''); 5 - 6 - return source.slice(0, 2).toUpperCase(); 7 - } 8 - 9 - function truncateDid(did: string): string { 10 - const suffix = did.replace(/^did:[a-z]+:/, ''); 11 - 12 - if (suffix.length <= 10) { 13 - return did; 14 - } 15 - 16 - return `${did.slice(0, 12)}…${suffix.slice(-4)}`; 17 - } 2 + import { initialsFromHandle, truncateDid } from '@/lib/handle'; 18 3 19 4 export function UserInfo({ 20 5 handle,
+169
resources/js/components/window-chrome.tsx
··· 1 + import { 2 + LogoutSquare01Icon, 3 + PlusSignIcon, 4 + Settings03Icon, 5 + UserCircleIcon, 6 + } from '@hugeicons/core-free-icons'; 7 + import { HugeiconsIcon } from '@hugeicons/react'; 8 + import { Deferred, Link, router, usePage } from '@inertiajs/react'; 9 + 10 + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 11 + import { Button } from '@/components/ui/button'; 12 + import { 13 + DropdownMenu, 14 + DropdownMenuContent, 15 + DropdownMenuItem, 16 + DropdownMenuSeparator, 17 + DropdownMenuTrigger, 18 + } from '@/components/ui/dropdown-menu'; 19 + import { Skeleton } from '@/components/ui/skeleton'; 20 + import { initialsFromHandle, truncateDid } from '@/lib/handle'; 21 + import { cn } from '@/lib/utils'; 22 + import { consume, create, discover, logout } from '@/routes'; 23 + import { edit as editAppearance } from '@/routes/appearance'; 24 + import type { Profile } from '@/types/auth'; 25 + 26 + type Tab = { 27 + label: string; 28 + href: string; 29 + }; 30 + 31 + const TABS: Tab[] = [ 32 + { label: 'Discover', href: discover().url }, 33 + { label: 'Consume', href: consume().url }, 34 + { label: 'Create', href: create().url }, 35 + ]; 36 + 37 + export function WindowChrome() { 38 + const { url, props } = usePage(); 39 + const auth = props.auth; 40 + 41 + const handleLogout = () => { 42 + router.flushAll(); 43 + router.post(logout().url); 44 + }; 45 + 46 + return ( 47 + <header className="flex h-14 shrink-0 items-center justify-between px-20"> 48 + <nav className="flex items-center gap-6"> 49 + {TABS.map((tab) => { 50 + const isActive = url === tab.href; 51 + 52 + return ( 53 + <Link 54 + key={tab.href} 55 + href={tab.href} 56 + className={cn( 57 + 'relative text-sm font-medium transition-colors outline-none focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-ring focus-visible:outline-solid', 58 + isActive 59 + ? 'text-foreground' 60 + : 'text-muted-foreground hover:text-foreground', 61 + )} 62 + > 63 + {tab.label} 64 + {isActive && ( 65 + <span 66 + aria-hidden 67 + className="absolute -bottom-2 left-1/2 size-1 -translate-x-1/2 rounded-full bg-primary" 68 + /> 69 + )} 70 + </Link> 71 + ); 72 + })} 73 + </nav> 74 + 75 + <div className="flex items-center gap-2 text-muted-foreground"> 76 + <Button variant="ghost" size="icon-sm" aria-label="Add source"> 77 + <HugeiconsIcon icon={PlusSignIcon} className="size-5" /> 78 + </Button> 79 + <DropdownMenu> 80 + <DropdownMenuTrigger 81 + render={ 82 + <Button 83 + variant="ghost" 84 + size="icon-sm" 85 + aria-label="Account" 86 + /> 87 + } 88 + > 89 + <HugeiconsIcon 90 + icon={UserCircleIcon} 91 + className="size-5" 92 + /> 93 + </DropdownMenuTrigger> 94 + <DropdownMenuContent align="end" className="w-56"> 95 + {auth.user && ( 96 + <> 97 + <Deferred 98 + data="auth.profile" 99 + fallback={<UserHeaderSkeleton />} 100 + > 101 + <UserHeader 102 + handle={auth.handle} 103 + did={auth.user.did} 104 + profile={auth.profile ?? null} 105 + /> 106 + </Deferred> 107 + <DropdownMenuSeparator /> 108 + </> 109 + )} 110 + <DropdownMenuItem 111 + render={<Link href={editAppearance().url} />} 112 + > 113 + <HugeiconsIcon icon={Settings03Icon} /> 114 + Settings 115 + </DropdownMenuItem> 116 + <DropdownMenuItem onClick={handleLogout}> 117 + <HugeiconsIcon icon={LogoutSquare01Icon} /> 118 + Log out 119 + </DropdownMenuItem> 120 + </DropdownMenuContent> 121 + </DropdownMenu> 122 + </div> 123 + </header> 124 + ); 125 + } 126 + 127 + function UserHeader({ 128 + handle, 129 + did, 130 + profile, 131 + }: { 132 + handle: string | null; 133 + did: string; 134 + profile: Profile | null; 135 + }) { 136 + const handleLine = handle ? `@${handle}` : truncateDid(did); 137 + const displayName = profile?.displayName?.trim() || handleLine; 138 + 139 + return ( 140 + <div className="flex items-center gap-2 px-2 py-1.5 transition-opacity duration-200"> 141 + <Avatar className="size-8 shrink-0"> 142 + {profile?.avatar && <AvatarImage src={profile.avatar} alt="" />} 143 + <AvatarFallback className="bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"> 144 + {initialsFromHandle(handle, did)} 145 + </AvatarFallback> 146 + </Avatar> 147 + <div className="flex min-w-0 flex-col leading-tight"> 148 + <span className="truncate text-sm font-medium text-foreground"> 149 + {displayName} 150 + </span> 151 + <span className="truncate text-xs font-light text-muted-foreground"> 152 + {handleLine} 153 + </span> 154 + </div> 155 + </div> 156 + ); 157 + } 158 + 159 + function UserHeaderSkeleton() { 160 + return ( 161 + <div className="flex items-center gap-2 px-2 py-1.5"> 162 + <Skeleton className="size-8 shrink-0 animate-none rounded-full" /> 163 + <div className="flex min-w-0 flex-col gap-1.5"> 164 + <Skeleton className="h-3.5 w-24 animate-none" /> 165 + <Skeleton className="h-3 w-16 animate-none" /> 166 + </div> 167 + </div> 168 + ); 169 + }
+12
resources/js/components/window-footer.tsx
··· 1 + import AppLogoIcon from '@/components/app-logo-icon'; 2 + 3 + export function WindowFooter() { 4 + return ( 5 + <footer className="flex flex-col items-center gap-1.5 py-6"> 6 + <AppLogoIcon className="size-5 text-muted-foreground/40" /> 7 + <p className="font-handwritten text-sm text-muted-foreground/40"> 8 + Your calm window into the Atmosphere. 9 + </p> 10 + </footer> 11 + ); 12 + }
+14 -11
resources/js/components/window.tsx
··· 1 1 import type { ReactNode } from 'react'; 2 + import { LevelContext } from '@/lib/level-context'; 2 3 import { cn } from '@/lib/utils'; 3 4 4 5 type WindowVariant = 'plain' | 'sunrise'; ··· 10 11 }; 11 12 12 13 const VARIANT_STYLES: Record<WindowVariant, string> = { 13 - plain: 'bg-gray-50 dark:bg-gray-900', 14 + plain: 'border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900', 14 15 sunrise: 'bg-sunrise shadow-[inset_0_0_0_1px_rgba(255,255,255,0.4)]', 15 16 }; 16 17 ··· 20 21 className, 21 22 }: WindowProps) { 22 23 return ( 23 - <div 24 - data-slot="window" 25 - className={cn( 26 - 'overflow-hidden rounded-tl-[4rem] rounded-tr-[4rem] rounded-br-[0.5rem] rounded-bl-[0.5rem]', 27 - VARIANT_STYLES[variant], 28 - className, 29 - )} 30 - > 31 - {children} 32 - </div> 24 + <LevelContext.Provider value={1}> 25 + <div 26 + data-slot="window" 27 + className={cn( 28 + 'overflow-hidden rounded-tl-[4rem] rounded-tr-[4rem] rounded-br-[0.5rem] rounded-bl-[0.5rem]', 29 + VARIANT_STYLES[variant], 30 + className, 31 + )} 32 + > 33 + {children} 34 + </div> 35 + </LevelContext.Provider> 33 36 ); 34 37 }
+27
resources/js/layouts/window-layout.tsx
··· 1 + import type { ReactNode } from 'react'; 2 + 3 + import { Window } from '@/components/window'; 4 + import { WindowChrome } from '@/components/window-chrome'; 5 + import { WindowFooter } from '@/components/window-footer'; 6 + 7 + type WindowLayoutProps = { 8 + children: ReactNode; 9 + }; 10 + 11 + export default function WindowLayout({ children }: WindowLayoutProps) { 12 + return ( 13 + <div className="flex h-dvh flex-col"> 14 + <WindowChrome /> 15 + <main className="min-h-0 flex-1 px-4 pb-4"> 16 + <Window variant="plain" className="h-full"> 17 + <div className="h-full overflow-y-auto"> 18 + <div className="flex min-h-full flex-col"> 19 + <div className="flex-1">{children}</div> 20 + <WindowFooter /> 21 + </div> 22 + </div> 23 + </Window> 24 + </main> 25 + </div> 26 + ); 27 + }
+15
resources/js/lib/handle.ts
··· 1 + export function initialsFromHandle(handle: string | null, did: string): string { 2 + const source = handle ?? did.replace(/^did:[a-z]+:/, ''); 3 + 4 + return source.slice(0, 2).toUpperCase(); 5 + } 6 + 7 + export function truncateDid(did: string): string { 8 + const suffix = did.replace(/^did:[a-z]+:/, ''); 9 + 10 + if (suffix.length <= 10) { 11 + return did; 12 + } 13 + 14 + return `${did.slice(0, 12)}…${suffix.slice(-4)}`; 15 + }
+5
resources/js/pages/consume.tsx
··· 1 + import { Head } from '@inertiajs/react'; 2 + 3 + export default function Consume() { 4 + return <Head title="Consume" />; 5 + }
+5
resources/js/pages/create.tsx
··· 1 + import { Head } from '@inertiajs/react'; 2 + 3 + export default function Create() { 4 + return <Head title="Create" />; 5 + }
-36
resources/js/pages/dashboard.tsx
··· 1 - import { Head } from '@inertiajs/react'; 2 - import { PlaceholderPattern } from '@/components/ui/placeholder-pattern'; 3 - import { dashboard } from '@/routes'; 4 - 5 - export default function Dashboard() { 6 - return ( 7 - <> 8 - <Head title="Dashboard" /> 9 - <div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"> 10 - <div className="grid auto-rows-min gap-4 md:grid-cols-3"> 11 - <div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> 12 - <PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> 13 - </div> 14 - <div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> 15 - <PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> 16 - </div> 17 - <div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> 18 - <PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> 19 - </div> 20 - </div> 21 - <div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"> 22 - <PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> 23 - </div> 24 - </div> 25 - </> 26 - ); 27 - } 28 - 29 - Dashboard.layout = { 30 - breadcrumbs: [ 31 - { 32 - title: 'Dashboard', 33 - href: dashboard(), 34 - }, 35 - ], 36 - };
+5
resources/js/pages/discover.tsx
··· 1 + import { Head } from '@inertiajs/react'; 2 + 3 + export default function Discover() { 4 + return <Head title="Discover" />; 5 + }
+3 -3
resources/js/pages/welcome.tsx
··· 3 3 import AppLogoIcon from '@/components/app-logo-icon'; 4 4 import { Button } from '@/components/ui/button'; 5 5 import { Window } from '@/components/window'; 6 - import { dashboard, login } from '@/routes'; 6 + import { consume, login } from '@/routes'; 7 7 8 8 export default function Welcome() { 9 9 const { auth } = usePage().props; 10 - const target = auth.user ? dashboard().url : login().url; 10 + const target = auth.user ? consume().url : login().url; 11 11 12 12 useEffect(() => { 13 13 const handler = (event: KeyboardEvent) => { ··· 25 25 } 26 26 27 27 event.preventDefault(); 28 - router.visit(auth.user ? dashboard().url : login().url); 28 + router.visit(auth.user ? consume().url : login().url); 29 29 }; 30 30 window.addEventListener('keydown', handler); 31 31
+6
resources/js/types/auth.ts
··· 5 5 updated_at: string; 6 6 }; 7 7 8 + export type Profile = { 9 + avatar: string | null; 10 + displayName: string | null; 11 + }; 12 + 8 13 export type Auth = { 9 14 user: User | null; 10 15 handle: string | null; 16 + profile?: Profile | null; 11 17 };
+3 -1
routes/web.php
··· 37 37 ->name('bluesky.oauth.redirect'); 38 38 39 39 Route::middleware(['auth'])->group(function () { 40 - Route::inertia('dashboard', 'dashboard')->name('dashboard'); 40 + Route::inertia('discover', 'discover')->name('discover'); 41 + Route::inertia('consume', 'consume')->name('consume'); 42 + Route::inertia('create', 'create')->name('create'); 41 43 }); 42 44 43 45 require __DIR__.'/settings.php';
+2 -2
tests/Feature/Auth/OAuthCallbackTest.php
··· 18 18 19 19 $this->withSession(['atproto.hint' => 'alice.bsky.social']) 20 20 ->get(route('bluesky.oauth.redirect')) 21 - ->assertRedirect(route('dashboard')); 21 + ->assertRedirect(route('consume')); 22 22 23 23 $user = User::find('did:plc:testuser1234567890abcd'); 24 24 ··· 46 46 $this->fakeBlueskyCallback($session); 47 47 48 48 $this->get(route('bluesky.oauth.redirect')) 49 - ->assertRedirect(route('dashboard')); 49 + ->assertRedirect(route('consume')); 50 50 51 51 $existing->refresh(); 52 52 expect($existing->refresh_token)->toBe('new-refresh-token')
+3 -3
tests/Feature/DashboardTest.php tests/Feature/ConsumeTest.php
··· 3 3 use App\Models\User; 4 4 5 5 test('guests are redirected to the login page', function () { 6 - $response = $this->get(route('dashboard')); 6 + $response = $this->get(route('consume')); 7 7 $response->assertRedirect(route('login')); 8 8 }); 9 9 10 - test('authenticated users can visit the dashboard', function () { 10 + test('authenticated users can visit consume', function () { 11 11 $user = User::factory()->create(); 12 12 $this->actingAs($user); 13 13 14 - $response = $this->get(route('dashboard')); 14 + $response = $this->get(route('consume')); 15 15 $response->assertOk(); 16 16 });
+1 -1
tests/Feature/InertiaSharedDataTest.php
··· 17 17 18 18 $this->actingAs($user) 19 19 ->withSession(['atproto.handle' => 'alice.bsky.social']) 20 - ->get(route('dashboard')) 20 + ->get(route('consume')) 21 21 ->assertInertia(fn ($page) => $page 22 22 ->where('auth.user.did', $user->did) 23 23 ->where('auth.handle', 'alice.bsky.social')