Implement changelog dialog
Some checks failed
Docs / build (push) Has been cancelled

This commit is contained in:
Полина 2025-01-25 22:31:59 +03:00
parent a3daff891f
commit 67292719a7
7 changed files with 395 additions and 70 deletions

View file

@ -18,6 +18,8 @@
"@mtcute/convert": "^0.19.8",
"@mtcute/web": "^0.19.5",
"@nanostores/persistent": "^0.10.2",
"@tanstack/solid-query": "^5.64.2",
"@tanstack/solid-query-persist-client": "^5.64.2",
"@tanstack/solid-table": "^8.20.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -1,9 +1,15 @@
import type { RunnerController } from './components/runner/Runner.tsx'
import { ColorModeProvider, ColorModeScript } from '@kobalte/core'
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/solid-query'
import { LucidePartyPopper } from 'lucide-solid'
import { workerInit } from 'mtcute-repl-worker/client'
import { createSignal, lazy, onCleanup, onMount, Show } from 'solid-js'
import { toast } from 'solid-sonner'
import { ChangelogDialog } from './components/changelog/Changelog.tsx'
import { EditorTabs } from './components/editor/EditorTabs.tsx'
import { NavbarMenu } from './components/nav/NavbarMenu.tsx'
import { Runner } from './components/runner/Runner.tsx'
@ -14,8 +20,11 @@ import { Toaster } from './lib/components/ui/sonner.tsx'
const Editor = lazy(() => import('./components/editor/Editor.tsx'))
const queryClient = new QueryClient()
export function App() {
const [updating, setUpdating] = createSignal(true)
const [showChangelog, setShowChangelog] = createSignal(false)
const [showSettings, setShowSettings] = createSignal(false)
const [settingsTab, setSettingsTab] = createSignal<SettingsTab>('accounts')
const [runnerController, setRunnerController] = createSignal<RunnerController>()
@ -27,6 +36,32 @@ export function App() {
let workerIframe!: HTMLIFrameElement
onMount(() => {
const localBuild = localStorage.getItem('repl:buildVersion')
const latestBuild: string = import.meta.env.BUILD_VERSION
if (localBuild === null || new Date(localBuild) !== new Date(latestBuild)) {
localStorage.setItem('repl:buildVersion', latestBuild)
setTimeout(() => {
toast.custom(t => (
<div
class="flex cursor-pointer items-center rounded-md border p-6"
onClick={() => {
setShowChangelog(true)
toast.dismiss(t)
}}
>
<LucidePartyPopper class="ml-1.5 mr-4" />
<div class="flex flex-col">
<div class="text-sm font-semibold">Playground updated!</div>
<div class="text-sm opacity-90">Click here to see the latest changes.</div>
</div>
</div>
), {
important: true,
})
}, 1000)
}
workerInit(workerIframe).then(() => {
setIframeLoading(false)
})
@ -46,79 +81,89 @@ export function App() {
<div class="flex h-screen w-screen flex-col overflow-hidden">
<Toaster />
<ColorModeScript />
<ColorModeProvider>
<iframe
ref={workerIframe}
class="invisible size-0"
src={import.meta.env.VITE_IFRAME_URL}
on:error={() => {
toast('Worker iframe failed to load, try reloading the page')
}}
/>
<nav class="relative flex h-auto w-full shrink-0 flex-row items-center justify-between overflow-hidden px-4 py-2">
<h1 class="font-mono text-base">
@mtcute/
<b>playground</b>
</h1>
<QueryClientProvider client={queryClient}>
<ColorModeProvider>
<iframe
ref={workerIframe}
class="invisible size-0"
src={import.meta.env.VITE_IFRAME_URL}
on:error={() => {
toast('Worker iframe failed to load, try reloading the page')
}}
/>
<nav class="relative flex h-auto w-full shrink-0 flex-row items-center justify-between overflow-hidden px-4 py-2">
<h1 class="font-mono text-base">
@mtcute/
<b>playground</b>
</h1>
<div class="flex items-center gap-1">
<NavbarMenu
iframeLoading={iframeLoading()}
onShowAccounts={() => {
setShowSettings(true)
setSettingsTab('accounts')
}}
onShowSettings={() => {
setShowSettings(true)
}}
/>
</div>
</nav>
<div class="h-px shrink-0 bg-border" />
<Show
when={!updating()}
fallback={(
<Updater
iframeLoading={iframeLoading()}
onComplete={() => setUpdating(false)}
/>
)}
>
<Resizable sizes={sizes()} onSizesChange={e => setSizes(e)} orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
<EditorTabs />
<Editor
class="size-full"
onRun={() => runnerController()?.run()}
<div class="flex items-center gap-1">
<NavbarMenu
iframeLoading={iframeLoading()}
onShowAccounts={() => {
setShowSettings(true)
setSettingsTab('accounts')
}}
onShowChangelog={() => {
setShowChangelog(true)
}}
onShowSettings={() => {
setShowSettings(true)
}}
/>
</ResizablePanel>
<ResizableHandle
withHandle
onDblClick={() => {
setSizes([0.5, 0.5])
}}
onMouseDown={() => setIsResizing(true)}
onMouseUp={() => setIsResizing(false)}
/>
<ResizablePanel
class="flex max-h-full flex-col overflow-hidden"
minSize={0.2}
>
<Runner
isResizing={isResizing()}
controllerRef={setRunnerController}
</div>
</nav>
<div class="bg-border h-px shrink-0" />
<Show
when={!updating()}
fallback={(
<Updater
iframeLoading={iframeLoading()}
onComplete={() => setUpdating(false)}
/>
</ResizablePanel>
</Resizable>
</Show>
)}
>
<Resizable sizes={sizes()} onSizesChange={e => setSizes(e)} orientation="horizontal" class="size-full max-h-[calc(100vh-57px)]">
<ResizablePanel class="h-full overflow-x-auto overflow-y-hidden" minSize={0.2}>
<EditorTabs />
<Editor
class="size-full"
onRun={() => runnerController()?.run()}
/>
</ResizablePanel>
<ResizableHandle
withHandle
onDblClick={() => {
setSizes([0.5, 0.5])
}}
onMouseDown={() => setIsResizing(true)}
onMouseUp={() => setIsResizing(false)}
/>
<ResizablePanel
class="flex max-h-full flex-col overflow-hidden"
minSize={0.2}
>
<Runner
isResizing={isResizing()}
controllerRef={setRunnerController}
/>
</ResizablePanel>
</Resizable>
</Show>
<SettingsDialog
show={showSettings()}
onClose={() => setShowSettings(false)}
tab={settingsTab()}
onTabChange={setSettingsTab}
/>
</ColorModeProvider>
<ChangelogDialog
show={showChangelog()}
onClose={() => setShowChangelog(false)}
/>
<SettingsDialog
show={showSettings()}
onClose={() => setShowSettings(false)}
tab={settingsTab()}
onTabChange={setSettingsTab}
/>
</ColorModeProvider>
</QueryClientProvider>
</div>
)
}

View file

@ -0,0 +1,217 @@
import {
createQuery,
} from '@tanstack/solid-query'
import { createSignal, ErrorBoundary, For, onMount, Show, Suspense } from 'solid-js'
import { Avatar, AvatarFallback, AvatarImage, makeAvatarFallbackText } from '../../lib/components/ui/avatar'
import { Button } from '../../lib/components/ui/button'
import { Dialog, DialogContent } from '../../lib/components/ui/dialog'
import { Skeleton } from '../../lib/components/ui/skeleton'
interface Commit {
message?: string
authorName?: string
authorUsername?: string
authorAvatar?: string
authorUrl?: string
date?: string
commitHash?: string
commitUrl?: string
}
function getRandomSizes(len: number) {
return Array.from({ length: len }, () => ({
msg: Math.random() * 192 + 128,
sub: [
Math.random() * 32 + 32,
Math.random() * 32 + 32,
Math.random() * 32 + 32,
],
}))
}
const lastPageRegex = /page=(\d*)>; rel="last"/g
export function ChangelogDialog(props: {
show: boolean
onClose: () => void
}) {
const commitsPerPage = 15
const [lastPage, setLastPage] = createSignal(1)
onMount(async () => {
const result = await fetch(`https://api.github.com/repos/mtcute/repl/commits?per_page=${commitsPerPage}`, {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
'Accept': 'application/vnd.github+json',
},
})
const link = result.headers.get('link')
if (link === null) return
const match = lastPageRegex.exec(link)
if (match !== null && match.length > 1) {
setLastPage(Number.parseInt(match[1]))
}
})
const [page, setPage] = createSignal(1)
const commitsQuery = createQuery<Commit[]>(() => ({
queryKey: ['repo commits', page()],
queryFn: async () => {
const result = await fetch(`https://api.github.com/repos/mtcute/repl/commits?per_page=${commitsPerPage}&page=${page()}`, {
headers: {
'X-GitHub-Api-Version': '2022-11-28',
'Accept': 'application/vnd.github+json',
},
})
if (!result.ok) throw new Error('Failed to fetch data')
return result.json().then(e => e.map((e: any) => ({
message: e?.commit?.message?.split(':').at(-1)?.trim(),
authorName: e?.commit?.author?.name,
authorUsername: e?.author?.login,
authorAvatar: e?.committer?.avatar_url,
authorUrl: e?.author?.html_url,
date: e?.commit?.author?.date,
commitHash: e?.sha,
commitUrl: e?.html_url,
})))
},
staleTime: 1000 * 60 * 10, // 10 minutes
throwOnError: true, // Throw an error if the query fails
}))
const [skeletonSizes, setSkeletonSizes] = createSignal<{ msg: number, sub: number[] }[]>(getRandomSizes(commitsPerPage))
return (
<Dialog
open={props.show}
onOpenChange={open => !open && props.onClose()}
>
<DialogContent class="flex w-[calc(100vw-96px)] max-w-[960px] flex-col gap-2 overflow-auto p-2">
<div class="flex flex-row items-center gap-3 px-2">
<h1 class="text-lg font-bold">Changelog</h1>
<div class="text-sm opacity-75">
<Show when={commitsQuery.data !== null}>
{(() => {
const date = commitsQuery.data?.at(0)?.date
if (date) return new Date(date).toLocaleDateString()
return '???'
})()}
&nbsp;to&nbsp;
{(() => {
const date = commitsQuery.data?.at(-1)?.date
if (date) return new Date(date).toLocaleDateString()
return '???'
})()}
</Show>
</div>
</div>
<div class="relative flex flex-col rounded-md border">
<ErrorBoundary fallback={(
<>
<For each={Array.from({ length: commitsPerPage }, (_, index) => index)}>
{_ => (
<div class="hover:bg-muted/50 box-content flex h-[24px] items-center justify-between px-3 py-1.5 [&:not(:last-child)]:border-b" />
)}
</For>
<div class="absolute flex size-full items-center justify-center">
<div class="bg-background rounded-md border p-4 shadow-sm">
Error while loading the changelog.
</div>
</div>
</>
)}
>
<Suspense fallback={(
<For each={Array.from({ length: commitsPerPage }, (_, index) => index)}>
{i => (
<div class="hover:bg-muted/50 box-content flex h-[24px] items-center justify-between px-3 py-1.5 [&:not(:last-child)]:border-b">
<Skeleton class="h-4" style={{ width: `${skeletonSizes()[i].msg}px` }} />
<div class="flex items-center text-xs">
<div class="flex opacity-50">
<Skeleton class="h-4" style={{ width: `${skeletonSizes()[i].sub[0]}px` }} />
&nbsp;&nbsp;
<Skeleton class="h-4" style={{ width: `${skeletonSizes()[i].sub[1]}px` }} />
&nbsp;&nbsp;
</div>
<div class="flex cursor-pointer items-center gap-1.5 hover:underline">
<div class="opacity-50">
<Skeleton class="h-4" style={{ width: `${skeletonSizes()[i].sub[2]}px` }} />
</div>
<Skeleton class="size-4 rounded-full" />
</div>
</div>
</div>
)}
</For>
)}
>
<For each={commitsQuery.data}>
{commit => (
<div class="hover:bg-muted/50 flex justify-between px-3 py-1.5 [&:not(:last-child)]:border-b">
<div>
<a href={commit.commitUrl} target="_blank" class="text-sm font-semibold capitalize hover:underline">
{commit.message}
</a>
</div>
<div class="flex items-center text-xs">
<div class="opacity-50">
{commit.date ? new Date(commit.date).toLocaleDateString() : 'unknown'}
&nbsp;&nbsp;
{commit.commitHash?.slice(0, 7) ?? 'unknown'}
&nbsp;&nbsp;
</div>
<a href={commit.authorUrl} target="_blank" class="flex cursor-pointer items-center gap-1.5 hover:underline">
<div class="opacity-50">
{commit.authorUsername ?? 'unknown'}
</div>
<Avatar class="size-4">
<AvatarImage src={commit.authorAvatar} />
<AvatarFallback class="whitespace-nowrap rounded-none">
{makeAvatarFallbackText(commit.authorUsername ?? 'unknown')}
</AvatarFallback>
</Avatar>
</a>
</div>
</div>
)}
</For>
<For each={Array.from({ length: commitsPerPage - (commitsQuery.data?.length ?? 0) })}>
{_ => (
<div class="hover:bg-muted/50 box-content flex h-[24px] justify-between px-3 py-1.5 [&:not(:last-child)]:border-b" />
)}
</For>
</Suspense>
</ErrorBoundary>
</div>
<div class="flex justify-center gap-4">
<Button
variant="ghost"
disabled={page() === 1}
onClick={() => {
setSkeletonSizes(getRandomSizes(commitsPerPage))
setPage(page() - 1)
}}
>
Previous
</Button>
<Button
variant="ghost"
disabled={page() === lastPage()}
onClick={() => {
setSkeletonSizes(getRandomSizes(commitsPerPage))
setPage(page() + 1)
}}
>
Next
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -1,7 +1,7 @@
import type { ConfigColorMode, MaybeConfigColorMode } from '@kobalte/core'
import type { DropdownMenuTriggerProps } from '@kobalte/core/dropdown-menu'
import { useColorMode } from '@kobalte/core'
import { LucideCheck, LucideChevronRight, LucideExternalLink, LucideLaptop, LucideLogIn, LucideMoon, LucideSun, LucideUsers } from 'lucide-solid'
import { LucideCheck, LucideChevronRight, LucideExternalLink, LucideLaptop, LucideLogIn, LucideMoon, LucideNotebook, LucideSun, LucideUsers } from 'lucide-solid'
import { SiGithub } from 'solid-icons/si'
import { createSignal, For, Show } from 'solid-js'
import { Button } from '../../lib/components/ui/button.tsx'
@ -26,6 +26,7 @@ import { AccountAvatar } from '../AccountAvatar.tsx'
export function NavbarMenu(props: {
onShowAccounts: () => void
onShowSettings: () => void
onShowChangelog: () => void
iframeLoading: boolean
}) {
const { setColorMode } = useColorMode()
@ -145,6 +146,10 @@ export function NavbarMenu(props: {
Manage accounts
<DropdownMenuShortcut> ,</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={props.onShowChangelog}>
<LucideNotebook class="mr-2 size-4" />
Changelog
</DropdownMenuItem>
<DropdownMenuItem
as="a"
class="cursor-pointer"

View file

@ -0,0 +1,13 @@
import { type ComponentProps, splitProps } from 'solid-js'
import { cn } from '../../utils.ts'
export function Skeleton(props: ComponentProps<'div'>) {
const [local, rest] = splitProps(props, ['class'])
return (
<div
class={cn('animate-pulse rounded-md bg-primary/10', local.class)}
{...rest}
/>
)
}

View file

@ -10,6 +10,9 @@ export default defineConfig((env): UserConfig => {
}
return {
define: {
'import.meta.env.BUILD_VERSION': JSON.stringify(new Date()),
},
optimizeDeps: {
exclude: ['@mtcute/wasm'],
},

View file

@ -88,6 +88,12 @@ importers:
'@nanostores/persistent':
specifier: ^0.10.2
version: 0.10.2(nanostores@0.11.3)
'@tanstack/solid-query':
specifier: ^5.64.2
version: 5.64.2(solid-js@1.9.4)
'@tanstack/solid-query-persist-client':
specifier: ^5.64.2
version: 5.64.2(@tanstack/solid-query@5.64.2(solid-js@1.9.4))(solid-js@1.9.4)
'@tanstack/solid-table':
specifier: ^8.20.5
version: 8.20.5(solid-js@1.9.4)
@ -1171,6 +1177,23 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tanstack/query-core@5.64.2':
resolution: {integrity: sha512-hdO8SZpWXoADNTWXV9We8CwTkXU88OVWRBcsiFrk7xJQnhm6WRlweDzMD+uH+GnuieTBVSML6xFa17C2cNV8+g==}
'@tanstack/query-persist-client-core@5.64.2':
resolution: {integrity: sha512-yhBClzqwReV7pA1J6FsyWCND++ceqv0nqv8NOrC9rTsou9XfAJ0PlSl7XFupu9ZwsBF6Ooalc9KSHa25WxJ0/Q==}
'@tanstack/solid-query-persist-client@5.64.2':
resolution: {integrity: sha512-EyX5+MJqgU4ZCiDG0l531zcs7kYi7JyDRJ1U82GS4e872WldEMmJh9PHCG6mLmrZy9DhYiQkTaiIv5XXUm1Mnw==}
peerDependencies:
'@tanstack/solid-query': ^5.64.2
solid-js: ^1.6.0
'@tanstack/solid-query@5.64.2':
resolution: {integrity: sha512-SxYRxU4hU4fBNBrFujxpohVdAcjeivCY0hZh+YkdBsyZ7NphJq6VulS4TKGt8LsGyOtMSullvsCvIlOza0n/iQ==}
peerDependencies:
solid-js: ^1.6.0
'@tanstack/solid-table@8.20.5':
resolution: {integrity: sha512-LsB/g/24CjBpccOcok+u+tfyqtU9SIQg5wf7ne54jRdEsy5YQnrpb5ATWZileHBduIG0p/1oE7UOA+DyjtnbDQ==}
engines: {node: '>=12'}
@ -3734,6 +3757,23 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tanstack/query-core@5.64.2': {}
'@tanstack/query-persist-client-core@5.64.2':
dependencies:
'@tanstack/query-core': 5.64.2
'@tanstack/solid-query-persist-client@5.64.2(@tanstack/solid-query@5.64.2(solid-js@1.9.4))(solid-js@1.9.4)':
dependencies:
'@tanstack/query-persist-client-core': 5.64.2
'@tanstack/solid-query': 5.64.2(solid-js@1.9.4)
solid-js: 1.9.4
'@tanstack/solid-query@5.64.2(solid-js@1.9.4)':
dependencies:
'@tanstack/query-core': 5.64.2
solid-js: 1.9.4
'@tanstack/solid-table@8.20.5(solid-js@1.9.4)':
dependencies:
'@tanstack/table-core': 8.20.5