diff --git a/apps/web/src/layout/WebLayout.tsx b/apps/web/src/layout/WebLayout.tsx
index 6baae376..715d712f 100644
--- a/apps/web/src/layout/WebLayout.tsx
+++ b/apps/web/src/layout/WebLayout.tsx
@@ -29,7 +29,7 @@ export default async function WebLayout({
{children}
{footer && }
-
+
)
}
diff --git a/build.js b/build.js
index cc3e9add..2951af1f 100644
--- a/build.js
+++ b/build.js
@@ -39,7 +39,9 @@ const external = builtinModules
'@alinea/iso',
'@alinea/sqlite-wasm',
'next',
- 'next/navigation',
+ 'next/navigation.js',
+ 'next/dynamic.js',
+ 'next/headers.js',
'@remix-run/node',
'@remix-run/react',
'react/jsx-runtime',
diff --git a/src/cli/serve/CreateLocalServer.ts b/src/cli/serve/CreateLocalServer.ts
index 8d398e20..bef5fa88 100644
--- a/src/cli/serve/CreateLocalServer.ts
+++ b/src/cli/serve/CreateLocalServer.ts
@@ -77,7 +77,12 @@ export function createLocalServer(
let currentBuild: Trigger = trigger(),
initial = true
const config = {
- external: ['next/navigation', 'next/headers', '@alinea/generated/store.js'],
+ external: [
+ 'next/navigation.js',
+ 'next/dynamic.js',
+ 'next/headers.js',
+ '@alinea/generated/store.js'
+ ],
format: 'esm',
target: 'esnext',
treeShaking: true,
diff --git a/src/core/driver/NextDriver.server.tsx b/src/core/driver/NextDriver.server.tsx
index abaf392c..0126db9c 100644
--- a/src/core/driver/NextDriver.server.tsx
+++ b/src/core/driver/NextDriver.server.tsx
@@ -1,3 +1,5 @@
+'use server'
+
import {JWTPreviews} from 'alinea/backend'
import {createCloudHandler} from 'alinea/cloud/server/CloudHandler'
import {parseChunkedCookies} from 'alinea/preview/ChunkCookieValue'
@@ -7,8 +9,9 @@ import {
PREVIEW_UPDATE_NAME
} from 'alinea/preview/PreviewConstants'
import {enums, object, string} from 'cito'
+import dynamic from 'next/dynamic.js'
import PLazy from 'p-lazy'
-import {Suspense, lazy} from 'react'
+import {Suspense} from 'react'
import {Client} from '../Client.js'
import {Config} from '../Config.js'
import {Entry} from '../Entry.js'
@@ -19,7 +22,7 @@ import {User} from '../User.js'
import {createSelection} from '../pages/CreateSelection.js'
import {Realm} from '../pages/Realm.js'
import {DefaultDriver} from './DefaultDriver.server.js'
-import {NextApi} from './NextDriver.js'
+import {NextApi, PreviewProps} from './NextDriver.js'
const SearchParams = object({
token: string,
@@ -139,14 +142,36 @@ class NextDriver extends DefaultDriver implements NextApi {
})
}
- previews = async (): Promise => {
+ previews = async ({
+ widget,
+ workspace,
+ root
+ }: PreviewProps): Promise => {
const {draftMode} = await import('next/headers.js')
const [draftStatus] = outcome(() => draftMode())
const isDraft = draftStatus?.isEnabled
if (!isDraft) return null
const user = await this.user()
- const NextPreviews = lazy(() => import('alinea/core/driver/NextPreviews'))
- return {user && }
+ const NextPreviews = dynamic(
+ () => import('alinea/core/driver/NextPreviews'),
+ {ssr: false}
+ )
+ const devUrl = process.env.ALINEA_DEV_SERVER
+ const dashboardUrl =
+ devUrl ?? this.config.dashboard?.dashboardUrl ?? '/admin.html'
+ return (
+
+ {user && (
+
+ )}
+
+ )
}
}
diff --git a/src/core/driver/NextDriver.tsx b/src/core/driver/NextDriver.tsx
index 7f4bcc37..c2fe079f 100644
--- a/src/core/driver/NextDriver.tsx
+++ b/src/core/driver/NextDriver.tsx
@@ -3,9 +3,15 @@ import {Config} from '../Config.js'
import {User} from '../User.js'
import {DefaultDriver} from './DefaultDriver.js'
+export interface PreviewProps {
+ widget?: boolean
+ workspace?: string
+ root?: string
+}
+
export interface NextApi extends CMSApi {
user(): Promise
- previews(): Promise
+ previews(params: PreviewProps): Promise
backendHandler(request: Request): Promise
previewHandler(request: Request): Promise
}
diff --git a/src/core/driver/NextPreviews.tsx b/src/core/driver/NextPreviews.tsx
index 0e01c8f2..0891a842 100644
--- a/src/core/driver/NextPreviews.tsx
+++ b/src/core/driver/NextPreviews.tsx
@@ -8,16 +8,223 @@ import {
} from 'alinea/preview/PreviewConstants'
import {usePreview} from 'alinea/preview/react'
// @ts-ignore
-import {useRouter} from 'next/navigation'
+import {IcRoundEdit} from 'alinea/ui/icons/IcRoundEdit'
+import {IcRoundExitToApp} from 'alinea/ui/icons/IcRoundExitToApp'
+import {usePathname, useRouter} from 'next/navigation.js'
+import {PropsWithChildren, useEffect, useState} from 'react'
+import {createPortal} from 'react-dom'
import {User} from '../User.js'
const MAX_CHUNKS = 5
+const styles = `
+ :host {
+ display: contents;
+ }
+ .previews {
+ position: fixed;
+ bottom: 15px;
+ left: 0;
+ z-index: 9999;
+ }
+ .inner {
+ display: flex;
+ align-items: center;
+ background: #fff;
+ border-radius: 17.5px;
+ box-shadow: 0 0 1.4px rgba(0,0,0,.1), 0 2px 3.5px rgba(0,0,0,.1);
+ z-index: 1000;
+ height: 35px;
+ font-size: 14px;
+ font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ transform: translateX(-50%);
+ border: 1.5px solid #E4E4E7;
+ transition: border 0.2s ease-out;
+ animation: fade-in 0.3s ease-out;
+ }
+ .inner.is-centered {
+ border-color: #8189e5;
+ }
+ .logo {
+ display: block;
+ height: 25px;
+ width: auto;
+ flex-shrink: 0;
+ }
+ .icon {
+ display: block;
+ font-size: 16px;
+ }
+ .button {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ color: #596e8d;
+ white-space: nowrap;
+ height: 100%;
+ width: 45px;
+ border-radius: 5px;
+ }
+ .button:hover {
+ color: #000;
+ }
+ .separator {
+ display: block;
+ border-left: 1px solid #E4E4E7;
+ height: 16px;
+ }
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, 10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%);
+ }
+ }
+`
+
+function PreviewWidget({dashboardUrl, workspace, root}: NextPreviewsProps) {
+ const [closed, setClosed] = useState(false)
+ const router = useRouter()
+ const pathname = usePathname()
+ const adminUrl = new URL(dashboardUrl, location.origin)
+ const entryParams = new URLSearchParams({url: pathname})
+ if (workspace) entryParams.set('workspace', workspace)
+ if (root) entryParams.set('root', root)
+ const entryUrl = new URL(`#/edit?${entryParams}`, adminUrl)
+ const {isPreviewing} = usePreview({
+ async preview({entryId, phase, update}) {
+ const chunks = chunkCookieValue(PREVIEW_UPDATE_NAME, update)
+
+ // Todo: if we reached the limit show the user a modal or indication in
+ // the UI that previewing will be temporarily disabled until the changes
+ // are saved or published
+ if (chunks.length > MAX_CHUNKS) {
+ console.warn('Too many chunks, previewing will be disabled')
+ return
+ }
+
+ const now = Date.now()
+ const expiry = new Date(now + 10_000)
+ document.cookie = `${PREVIEW_ENTRYID_NAME}=${entryId};expires=${expiry.toUTCString()};path=/`
+ document.cookie = `${PREVIEW_PHASE_NAME}=${phase};expires=${expiry.toUTCString()};path=/`
+ for (const {name, value} of chunks) {
+ document.cookie = `${name}=${value};expires=${expiry.toUTCString()};path=/`
+ }
+ router.refresh()
+ }
+ })
+
+ const [xPosition, setXPosition] = useState(0.5)
+ const isCentered = Math.abs(xPosition - 0.5) < 0.05
+
+ function startDrag(event: React.MouseEvent) {
+ event.preventDefault()
+ let current = xPosition
+ const startX = event.clientX
+ const startOffset = xPosition
+ const windowWidth = window.innerWidth
+ const containerWidth = (event.currentTarget as HTMLElement).clientWidth
+ const minOffset = containerWidth / 2
+ function move(event: MouseEvent) {
+ const deltaX = event.clientX - startX
+ let newX = Math.max(0, Math.min(1, startOffset + deltaX / windowWidth))
+ const min = minOffset / windowWidth
+ if (newX < min) newX = min
+ const max = 1 - min
+ if (newX > max) newX = max
+ current = newX
+ setXPosition(newX)
+ }
+ function stop() {
+ const isCentered = Math.abs(current - 0.5) < 0.05
+ if (isCentered) setXPosition(0.5)
+ window.removeEventListener('mousemove', move)
+ window.removeEventListener('mouseup', stop)
+ }
+ window.addEventListener('mousemove', move)
+ window.addEventListener('mouseup', stop)
+ }
+
+ function exitPreview() {
+ const expiry = new Date(Date.now() - 10_000)
+ document.cookie = `${PREVIEW_ENTRYID_NAME}=;expires=${expiry.toUTCString()};path=/`
+ document.cookie = `${PREVIEW_PHASE_NAME}=;expires=${expiry.toUTCString()};path=/`
+ router.refresh()
+ setClosed(true)
+ }
+
+ useEffect(() => {
+ const reset = () => setXPosition(0.5)
+ window.addEventListener('resize', reset)
+ return () => window.removeEventListener('resize', reset)
+ }, [])
+
+ if (closed) return null
+
+ return (
+
+
+
+
+ )
+}
+
export interface NextPreviewsProps {
user: User
+ dashboardUrl: string
+ workspace?: string
+ root?: string
+ widget?: boolean
}
-export default function NextPreviews({user}: NextPreviewsProps) {
+export default function NextPreviews(props: NextPreviewsProps) {
const router = useRouter()
const {isPreviewing} = usePreview({
async preview({entryId, phase, update}) {
@@ -41,5 +248,19 @@ export default function NextPreviews({user}: NextPreviewsProps) {
router.refresh()
}
})
- return null
+ if (!props.widget) return null
+ return
+}
+
+function ShadowRoot({children}: PropsWithChildren) {
+ const [root, setRoot] = useState(null)
+ return (
+ {
+ if (el && !root) setRoot(el.attachShadow({mode: 'closed'}))
+ }}
+ >
+ {root && createPortal(children, root)}
+
+ )
}
diff --git a/src/dashboard/Routes.tsx b/src/dashboard/Routes.tsx
index 31492591..bd4b7efc 100644
--- a/src/dashboard/Routes.tsx
+++ b/src/dashboard/Routes.tsx
@@ -1,3 +1,8 @@
+import {Entry} from 'alinea/core'
+import {EntryLocation} from 'alinea/dashboard/DashboardNav'
+import {graphAtom} from 'alinea/dashboard/atoms/DbAtoms'
+import {locationAtom, useNavigate} from 'alinea/dashboard/atoms/LocationAtoms'
+import {useNav} from 'alinea/dashboard/hook/UseNav'
import {atom} from 'jotai'
import {atomFamily} from 'jotai/utils'
import {entryEditorAtoms} from './atoms/EntryEditorAtoms.js'
@@ -30,6 +35,41 @@ export const draftRoute = new Route({
component: DraftsOverview
})
-const routes = [draftRoute, entryRoute]
+const editLoader = atomFamily(() => {
+ return atom(async get => {
+ const location = get(locationAtom)
+ const searchParams = new URLSearchParams(location.search)
+ const url = searchParams.get('url')!
+ const workspace = searchParams.get('workspace') ?? undefined
+ const root = searchParams.get('root') ?? undefined
+ const where: Record = {url}
+ if (workspace) where.workspace = workspace
+ if (root) where.root = root
+ const graph = await get(graphAtom)
+ const entry = await graph.preferDraft.maybeGet(
+ Entry(where).select({
+ entryId: Entry.entryId,
+ root: Entry.root,
+ workspace: Entry.workspace
+ })
+ )
+ return entry
+ })
+})
+
+export const editRoute = new Route({
+ path: '/edit',
+ loader: editLoader,
+ component: EditRoute
+})
+
+function EditRoute(location: EntryLocation | null) {
+ const nav = useNav()
+ const navigate = useNavigate()
+ if (location) navigate(nav.entry(location))
+ return 'Not found'
+}
+
+const routes = [draftRoute, editRoute, entryRoute]
export const router = new Router({routes})
diff --git a/src/dashboard/atoms/DashboardAtoms.ts b/src/dashboard/atoms/DashboardAtoms.ts
index bdc5c3d1..8c02ab98 100644
--- a/src/dashboard/atoms/DashboardAtoms.ts
+++ b/src/dashboard/atoms/DashboardAtoms.ts
@@ -1,4 +1,4 @@
-import {Connection, Session, User} from 'alinea/core'
+import {Connection, Session, User, localUser} from 'alinea/core'
import {atom, useAtomValue, useSetAtom} from 'jotai'
import {useHydrateAtoms} from 'jotai/utils'
import {useEffect} from 'react'
@@ -14,16 +14,18 @@ export function useSetDashboardOptions(options: AppProps) {
const {client, config, dev} = options
const auth = config.dashboard?.auth
- if (dev || !auth)
+ if (dev || !auth) {
+ const userData = process.env.ALINEA_USER as string | undefined
useHydrateAtoms([
[
sessionAtom,
{
- user: JSON.parse(process.env.ALINEA_USER as string) as User,
+ user: userData ? (JSON.parse(userData) as User) : localUser,
cnx: client
}
]
])
+ }
const setDashboardOptions = useSetAtom(dashboardOptionsAtom)
useEffect(
diff --git a/src/dashboard/atoms/LocationAtoms.ts b/src/dashboard/atoms/LocationAtoms.ts
index 31eb7ae0..c4d5dfd1 100644
--- a/src/dashboard/atoms/LocationAtoms.ts
+++ b/src/dashboard/atoms/LocationAtoms.ts
@@ -89,6 +89,11 @@ export function useLocation() {
return useAtomValue(locationAtom)
}
+export function useSearchParams() {
+ const location = useLocation()
+ return new URLSearchParams(location.search)
+}
+
export function useNavigate() {
const setLocation = useSetAtom(locationAtom)
return function navigate(url: string) {
diff --git a/src/dashboard/atoms/RouterAtoms.tsx b/src/dashboard/atoms/RouterAtoms.tsx
index 9456e0ac..7655bf37 100644
--- a/src/dashboard/atoms/RouterAtoms.tsx
+++ b/src/dashboard/atoms/RouterAtoms.tsx
@@ -12,7 +12,7 @@ import {locationAtom, matchAtoms} from './LocationAtoms.js'
export interface RouteData {
path: string
- loader: (params: Record) => Atom>
+ loader?: (params: Record) => Atom>
component: FunctionComponent
}
@@ -50,7 +50,8 @@ export class Router {
matchingRouteWithData = atom(async (get): Promise => {
const match = get(this.matchingRoute)
if (!match) return undefined
- const data = await get(match.route.data.loader(match.params))
+ const {loader} = match.route.data
+ const data = loader ? await get(loader(match.params)) : {}
return {route: match.route, data, params: match.params}
})
diff --git a/src/ui/icons/IcRoundExitToApp.tsx b/src/ui/icons/IcRoundExitToApp.tsx
new file mode 100644
index 00000000..11896807
--- /dev/null
+++ b/src/ui/icons/IcRoundExitToApp.tsx
@@ -0,0 +1,18 @@
+import {SVGProps} from 'react'
+
+export function IcRoundExitToApp(props: SVGProps) {
+ return (
+
+ )
+}