Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions plugins/notion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@notionhq/client": "^3.1.3",
"framer-plugin": "3.10.2-alpha.0",
"motion": "^12.29.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"valibot": "^1.2.0"
Expand Down
44 changes: 44 additions & 0 deletions plugins/notion/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ form {
gap: 10px;
}

p a {
cursor: pointer;
}

.sticky-divider {
position: sticky;
top: 0;
Expand Down Expand Up @@ -249,6 +253,8 @@ select:not(:disabled) {
height: 100%;
padding: 0px 15px 15px 15px;
gap: 15px;
user-select: none;
-webkit-user-select: none;
}

.login-image {
Expand Down Expand Up @@ -296,7 +302,45 @@ select:not(:disabled) {
width: 100%;
}

.actions a {
display: contents;
}

.action-button {
flex: 1;
width: 100%;
}

/* Progress State */

.progress-bar-text {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
color: var(--framer-color-text-tertiary);
}

.progress-bar-percent {
font-weight: 600;
color: var(--framer-color-text);
}

.progress-bar {
height: 3px;
width: 100%;
flex-shrink: 0;
border-radius: 10px;
background-color: var(--framer-color-bg-tertiary);
position: relative;
}

.progress-bar-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 10px;
background-color: var(--framer-color-tint);
}
114 changes: 109 additions & 5 deletions plugins/notion/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import "./App.css"

import { APIErrorCode, APIResponseError } from "@notionhq/client"
import { framer, type ManagedCollection } from "framer-plugin"
import { useEffect, useLayoutEffect, useMemo, useState } from "react"
import { FramerPluginClosedError, framer, type ManagedCollection } from "framer-plugin"
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import auth from "./auth"
import { type DatabaseIdMap, type DataSource, getDataSource } from "./data"
import {
type DatabaseIdMap,
type DataSource,
getDataSource,
type SyncProgress,
shouldSyncExistingCollection,
syncExistingCollection,
} from "./data"
import { FieldMapping } from "./FieldMapping"
import { NoTableAccess } from "./NoAccess"
import { Progress } from "./Progress"
import { SelectDataSource } from "./SelectDataSource"
import { showAccessErrorUI, showFieldMappingUI, showLoginUI } from "./ui"
import { showAccessErrorUI, showFieldMappingUI, showLoginUI, showProgressUI } from "./ui"

interface AppProps {
collection: ManagedCollection
Expand All @@ -28,10 +36,103 @@ export function App({
previousIgnoredFieldIds,
previousDatabaseName,
existingCollectionDatabaseIdMap,
}: AppProps) {
const [isSyncMode, setIsSyncMode] = useState<boolean>(
shouldSyncExistingCollection({
previousSlugFieldId,
previousDatabaseId,
})
)
const [progress, setProgress] = useState<SyncProgress>({ current: 0, total: 0 })
const hasRunSyncRef = useRef(false)

useEffect(() => {
if (!isSyncMode || hasRunSyncRef.current) return

hasRunSyncRef.current = true

const task = async () => {
void showProgressUI()

try {
const { didSync } = await syncExistingCollection(
collection,
previousDatabaseId,
previousSlugFieldId,
previousIgnoredFieldIds,
previousLastSynced,
previousDatabaseName,
existingCollectionDatabaseIdMap,
setProgress
)

if (didSync) {
framer.closePlugin("Synchronization successful", {
variant: "success",
})
} else {
setIsSyncMode(false)
}
} catch (error) {
if (error instanceof FramerPluginClosedError) return

console.error(error)
setIsSyncMode(false)
framer.notify(error instanceof Error ? error.message : "Failed to sync collection", {
variant: "error",
durationMs: 10000,
})
}
}

void task()
}, [
isSyncMode,
collection,
previousDatabaseId,
previousSlugFieldId,
previousIgnoredFieldIds,
previousLastSynced,
previousDatabaseName,
existingCollectionDatabaseIdMap,
])

if (isSyncMode) {
return (
<Progress
current={progress.current}
total={progress.total}
contentFieldEnabled={progress.contentFieldEnabled}
/>
)
}

return (
<ManageApp
collection={collection}
previousDatabaseId={previousDatabaseId}
previousSlugFieldId={previousSlugFieldId}
previousLastSynced={previousLastSynced}
previousIgnoredFieldIds={previousIgnoredFieldIds}
previousDatabaseName={previousDatabaseName}
existingCollectionDatabaseIdMap={existingCollectionDatabaseIdMap}
/>
)
}

function ManageApp({
collection,
previousDatabaseId,
previousSlugFieldId,
previousLastSynced,
previousIgnoredFieldIds,
previousDatabaseName,
existingCollectionDatabaseIdMap,
}: AppProps) {
const [dataSource, setDataSource] = useState<DataSource | null>(null)
const [isLoadingDataSource, setIsLoadingDataSource] = useState(Boolean(previousDatabaseId))
const [hasAccessError, setHasAccessError] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)

// Support self-referencing databases by allowing the current collection to be referenced.
const databaseIdMap = useMemo(() => {
Expand All @@ -46,6 +147,8 @@ export function App({
try {
if (hasAccessError) {
await showAccessErrorUI()
} else if (isSyncing) {
await showProgressUI()
} else if (dataSource || isLoadingDataSource) {
await showFieldMappingUI()
} else {
Expand All @@ -60,7 +163,7 @@ export function App({
}

void showUI()
}, [dataSource, isLoadingDataSource, hasAccessError])
}, [dataSource, isLoadingDataSource, hasAccessError, isSyncing])

useEffect(() => {
if (!previousDatabaseId) {
Expand Down Expand Up @@ -149,6 +252,7 @@ export function App({
previousLastSynced={previousLastSynced}
previousIgnoredFieldIds={previousIgnoredFieldIds}
databaseIdMap={databaseIdMap}
setIsSyncing={setIsSyncing}
/>
)
}
23 changes: 16 additions & 7 deletions plugins/notion/src/FieldMapping.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type SyncProgress,
syncCollection,
} from "./data"
import { Progress } from "./Progress"
import { assert, syncMethods } from "./utils"

const labelByFieldTypeOption: Record<ManagedCollectionField["type"], string> = {
Expand Down Expand Up @@ -144,6 +145,7 @@ interface FieldMappingProps {
previousLastSynced: string | null
previousIgnoredFieldIds: string | null
databaseIdMap: DatabaseIdMap
setIsSyncing: (isSyncing: boolean) => void
}

export function FieldMapping({
Expand All @@ -153,6 +155,7 @@ export function FieldMapping({
previousLastSynced,
previousIgnoredFieldIds,
databaseIdMap,
setIsSyncing,
}: FieldMappingProps) {
const isAllowedToManage = useIsAllowedTo("ManagedCollection.setFields", ...syncMethods)

Expand Down Expand Up @@ -250,6 +253,7 @@ export function FieldMapping({
const task = async () => {
try {
setStatus("syncing-collection")
setIsSyncing(true)
setSyncProgress(null)
await framer.setCloseWarning("Synchronization in progress. Closing will cancel the sync.")

Expand Down Expand Up @@ -286,6 +290,7 @@ export function FieldMapping({
} finally {
await framer.setCloseWarning(false)
setStatus("mapping-fields")
setIsSyncing(false)
setSyncProgress(null)
}
}
Expand All @@ -301,7 +306,15 @@ export function FieldMapping({
)
}

const progressPercent = syncProgress ? ((syncProgress.current / syncProgress.total) * 100).toFixed(1) : null
if (isSyncing) {
return (
<Progress
current={syncProgress?.current ?? 0}
total={syncProgress?.total ?? 0}
contentFieldEnabled={syncProgress?.contentFieldEnabled ?? true}
/>
)
}

return (
<main className="framer-hide-scrollbar mapping">
Expand Down Expand Up @@ -352,15 +365,11 @@ export function FieldMapping({
<footer>
<hr className="sticky-top" />
<button
disabled={isSyncing || !isAllowedToManage}
disabled={!isAllowedToManage}
tabIndex={0}
title={!isAllowedToManage ? "Insufficient permissions" : undefined}
>
{isSyncing ? (
<>{!syncProgress ? <div className="framer-spinner" /> : <span>{progressPercent}%</span>}</>
) : (
<span>Import from {dataSourceName.trim() ? dataSourceName : "Untitled"}</span>
)}
<span>Import from {dataSourceName.trim() ? dataSourceName : "Untitled"}</span>
</button>
</footer>
</form>
Expand Down
75 changes: 75 additions & 0 deletions plugins/notion/src/Progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { framer } from "framer-plugin"
import { animate, motion, useMotionValue, useTransform } from "motion/react"
import { useEffect } from "react"

const LOADING_PHASE_MAX = 20
const LOADING_PHASE_K = 150

export function Progress({
current,
total,
contentFieldEnabled = true,
}: {
current: number
total: number
/** When false, loading phase spans 0–100% (no per-page content fetch). */
contentFieldEnabled?: boolean
}) {
const percent = getProgressPercent(current, total, contentFieldEnabled)
const formatter = new Intl.NumberFormat("en-US")
const formattedCurrent = formatter.format(current)
const formattedTotal = formatter.format(total)

const animatedValue = useMotionValue(0)

useEffect(() => {
// Clear menu while syncing
void framer.setMenu([])
}, [])

useEffect(() => {
void animate(animatedValue, percent, { type: "tween" })
}, [percent, animatedValue])

return (
<main>
<div className="progress-bar-text">
<span className="progress-bar-percent">{percent.toFixed(1).replace(".0", "")}%</span>
<span>
{formattedCurrent} / {formattedTotal}
</span>
</div>
<div className="progress-bar">
<motion.div
className="progress-bar-fill"
style={{
width: useTransform(() => `${animatedValue.get()}%`),
}}
/>
</div>
<p>
{current > 0 ? "Syncing" : "Loading data"}… please keep the plugin open until the process is complete.
</p>
</main>
)
}

function getProgressPercent(current: number, total: number, contentFieldEnabled: boolean): number {
if (total > 0 && contentFieldEnabled) {
if (current > 0) {
// Processing phase: base 20%, remaining 80% from current/total
return LOADING_PHASE_MAX + 80 * (current / total)
}
// Loading phase: 0–20% with total/(total+k) so we approach but never reach 20%
return LOADING_PHASE_MAX * (total / (total + LOADING_PHASE_K))
}
if (total > 0 && !contentFieldEnabled) {
if (current > 0) {
// No per-page fetch: loading is done, show 100%
return 100
}
// Loading phase: 0–100% with total/(total+k)
return 100 * (total / (total + LOADING_PHASE_K))
}
return 0
}
Loading
Loading