Extract anyclip
This commit is contained in:
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# AnyClip Video Manager - Extracted Source
|
||||||
|
|
||||||
|
Source code extracted from sourcemaps of `videomanager.anyclip.com`.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Next.js application for video content management, analytics, and publishing.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── src/
|
||||||
|
│ ├── modules/ # Feature modules (business logic)
|
||||||
|
│ ├── pages/ # Next.js page components
|
||||||
|
│ ├── client/ # Client-side utilities
|
||||||
|
│ ├── shared/ # Shared libraries
|
||||||
|
│ └── assets/
|
||||||
|
├── pages/ # Root Next.js pages (_app.tsx, _error.tsx)
|
||||||
|
├── client/ # Next.js client runtime
|
||||||
|
├── vendor/ # Bundled node_modules
|
||||||
|
└── sourcemaps/ # Original .map files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules (`src/modules/`)
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `analytics/` | Dashboards - monetization, video performance, custom reports |
|
||||||
|
| `editorial/` | Video editing - tagging, search, bulk actions, video details |
|
||||||
|
| `publishing/` | Content publishing and destination management |
|
||||||
|
| `marketplace/` | Marketplace accounts and dashboard |
|
||||||
|
| `xRay/` | X-Ray - campaigns, creatives, line items |
|
||||||
|
| `hubs/` | Content hubs management |
|
||||||
|
| `users/` | User management |
|
||||||
|
| `invitations/` | User invitation system |
|
||||||
|
| `forms/` | Form builder/management |
|
||||||
|
| `uploaderNew/` | Video upload functionality |
|
||||||
|
| `userRulesSettings/` | User rules and settings |
|
||||||
|
| `layout/` | App layout and Redux state |
|
||||||
|
| `common/` | Shared components - forms, tables, lists, tag selectors |
|
||||||
|
|
||||||
|
## Pages (`src/pages/`)
|
||||||
|
|
||||||
|
- `/analytics` - Analytics dashboard
|
||||||
|
- `/studio` - Studio interface
|
||||||
|
- `/personal-settings` - User settings
|
||||||
|
- `/hubs`, `/users`, `/invitations`, `/forms` - Management pages
|
||||||
|
- `/x-ray/*` - Campaign, creative, and line item analytics
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Next.js, React, TypeScript
|
||||||
|
- Redux (state management)
|
||||||
|
- Material-UI (components)
|
||||||
|
- Victory/D3 (charts)
|
||||||
12
client/add-base-path.ts
Normal file
12
client/add-base-path.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { addPathPrefix } from '../shared/lib/router/utils/add-path-prefix'
|
||||||
|
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||||
|
|
||||||
|
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||||
|
|
||||||
|
export function addBasePath(path: string, required?: boolean): string {
|
||||||
|
return normalizePathTrailingSlash(
|
||||||
|
process.env.__NEXT_MANUAL_CLIENT_BASE_PATH && !required
|
||||||
|
? path
|
||||||
|
: addPathPrefix(path, basePath)
|
||||||
|
)
|
||||||
|
}
|
||||||
13
client/add-locale.ts
Normal file
13
client/add-locale.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { addLocale as Fn } from '../shared/lib/router/utils/add-locale'
|
||||||
|
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||||
|
|
||||||
|
export const addLocale: typeof Fn = (path, ...args) => {
|
||||||
|
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||||
|
return normalizePathTrailingSlash(
|
||||||
|
(
|
||||||
|
require('../shared/lib/router/utils/add-locale') as typeof import('../shared/lib/router/utils/add-locale')
|
||||||
|
).addLocale(path, ...args)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
9
client/detect-domain-locale.ts
Normal file
9
client/detect-domain-locale.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { detectDomainLocale as Fn } from '../shared/lib/i18n/detect-domain-locale'
|
||||||
|
|
||||||
|
export const detectDomainLocale: typeof Fn = (...args) => {
|
||||||
|
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||||
|
return (
|
||||||
|
require('../shared/lib/i18n/detect-domain-locale') as typeof import('../shared/lib/i18n/detect-domain-locale')
|
||||||
|
).detectDomainLocale(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client/has-base-path.ts
Normal file
7
client/has-base-path.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { pathHasPrefix } from '../shared/lib/router/utils/path-has-prefix'
|
||||||
|
|
||||||
|
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||||
|
|
||||||
|
export function hasBasePath(path: string): boolean {
|
||||||
|
return pathHasPrefix(path, basePath)
|
||||||
|
}
|
||||||
150
client/head-manager.ts
Normal file
150
client/head-manager.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { setAttributesFromProps } from './set-attributes-from-props'
|
||||||
|
|
||||||
|
import type { JSX } from 'react'
|
||||||
|
|
||||||
|
function reactElementToDOM({ type, props }: JSX.Element): HTMLElement {
|
||||||
|
const el: HTMLElement = document.createElement(type)
|
||||||
|
setAttributesFromProps(el, props)
|
||||||
|
|
||||||
|
const { children, dangerouslySetInnerHTML } = props
|
||||||
|
if (dangerouslySetInnerHTML) {
|
||||||
|
el.innerHTML = dangerouslySetInnerHTML.__html || ''
|
||||||
|
} else if (children) {
|
||||||
|
el.textContent =
|
||||||
|
typeof children === 'string'
|
||||||
|
? children
|
||||||
|
: Array.isArray(children)
|
||||||
|
? children.join('')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a `nonce` is present on an element, browsers such as Chrome and Firefox strip it out of the
|
||||||
|
* actual HTML attributes for security reasons *when the element is added to the document*. Thus,
|
||||||
|
* given two equivalent elements that have nonces, `Element,isEqualNode()` will return false if one
|
||||||
|
* of those elements gets added to the document. Although the `element.nonce` property will be the
|
||||||
|
* same for both elements, the one that was added to the document will return an empty string for
|
||||||
|
* its nonce HTML attribute value.
|
||||||
|
*
|
||||||
|
* This custom `isEqualNode()` function therefore removes the nonce value from the `newTag` before
|
||||||
|
* comparing it to `oldTag`, restoring it afterwards.
|
||||||
|
*
|
||||||
|
* For more information, see:
|
||||||
|
* https://bugs.chromium.org/p/chromium/issues/detail?id=1211471#c12
|
||||||
|
*/
|
||||||
|
export function isEqualNode(oldTag: Element, newTag: Element) {
|
||||||
|
if (oldTag instanceof HTMLElement && newTag instanceof HTMLElement) {
|
||||||
|
const nonce = newTag.getAttribute('nonce')
|
||||||
|
// Only strip the nonce if `oldTag` has had it stripped. An element's nonce attribute will not
|
||||||
|
// be stripped if there is no content security policy response header that includes a nonce.
|
||||||
|
if (nonce && !oldTag.getAttribute('nonce')) {
|
||||||
|
const cloneTag = newTag.cloneNode(true) as typeof newTag
|
||||||
|
cloneTag.setAttribute('nonce', '')
|
||||||
|
cloneTag.nonce = nonce
|
||||||
|
return nonce === oldTag.nonce && oldTag.isEqualNode(cloneTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldTag.isEqualNode(newTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateElements(type: string, components: JSX.Element[]) {
|
||||||
|
const headEl = document.querySelector('head')
|
||||||
|
if (!headEl) return
|
||||||
|
|
||||||
|
const oldTags = new Set(headEl.querySelectorAll(`${type}[data-next-head]`))
|
||||||
|
|
||||||
|
if (type === 'meta') {
|
||||||
|
const metaCharset = headEl.querySelector('meta[charset]')
|
||||||
|
if (metaCharset !== null) {
|
||||||
|
oldTags.add(metaCharset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTags: Element[] = []
|
||||||
|
for (let i = 0; i < components.length; i++) {
|
||||||
|
const component = components[i]
|
||||||
|
const newTag = reactElementToDOM(component)
|
||||||
|
newTag.setAttribute('data-next-head', '')
|
||||||
|
|
||||||
|
let isNew = true
|
||||||
|
for (const oldTag of oldTags) {
|
||||||
|
if (isEqualNode(oldTag, newTag)) {
|
||||||
|
oldTags.delete(oldTag)
|
||||||
|
isNew = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
newTags.push(newTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const oldTag of oldTags) {
|
||||||
|
oldTag.parentNode?.removeChild(oldTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const newTag of newTags) {
|
||||||
|
// meta[charset] must be first element so special case
|
||||||
|
if (
|
||||||
|
newTag.tagName.toLowerCase() === 'meta' &&
|
||||||
|
newTag.getAttribute('charset') !== null
|
||||||
|
) {
|
||||||
|
headEl.prepend(newTag)
|
||||||
|
}
|
||||||
|
headEl.appendChild(newTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function initHeadManager(): {
|
||||||
|
mountedInstances: Set<unknown>
|
||||||
|
updateHead: (head: JSX.Element[]) => void
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
mountedInstances: new Set(),
|
||||||
|
updateHead: (head: JSX.Element[]) => {
|
||||||
|
const tags: Record<string, JSX.Element[]> = {}
|
||||||
|
|
||||||
|
head.forEach((h) => {
|
||||||
|
if (
|
||||||
|
// If the font tag is loaded only on client navigation
|
||||||
|
// it won't be inlined. In this case revert to the original behavior
|
||||||
|
h.type === 'link' &&
|
||||||
|
h.props['data-optimized-fonts']
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
document.querySelector(`style[data-href="${h.props['data-href']}"]`)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
h.props.href = h.props['data-href']
|
||||||
|
h.props['data-href'] = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = tags[h.type] || []
|
||||||
|
components.push(h)
|
||||||
|
tags[h.type] = components
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleComponent = tags.title ? tags.title[0] : null
|
||||||
|
let title = ''
|
||||||
|
if (titleComponent) {
|
||||||
|
const { children } = titleComponent.props
|
||||||
|
title =
|
||||||
|
typeof children === 'string'
|
||||||
|
? children
|
||||||
|
: Array.isArray(children)
|
||||||
|
? children.join('')
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
if (title !== document.title) document.title = title
|
||||||
|
;['meta', 'base', 'link', 'style', 'script'].forEach((type) => {
|
||||||
|
updateElements(type, tags[type] || [])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
1008
client/index.tsx
Normal file
1008
client/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
client/next.ts
Normal file
23
client/next.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import './webpack'
|
||||||
|
import '../lib/require-instrumentation-client'
|
||||||
|
|
||||||
|
import { initialize, hydrate, version, router, emitter } from './'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
next: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.next = {
|
||||||
|
version,
|
||||||
|
// router is initialized later so it has to be live-binded
|
||||||
|
get router() {
|
||||||
|
return router
|
||||||
|
},
|
||||||
|
emitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize({})
|
||||||
|
.then(() => hydrate())
|
||||||
|
.catch(console.error)
|
||||||
25
client/normalize-trailing-slash.ts
Normal file
25
client/normalize-trailing-slash.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
|
||||||
|
import { parsePath } from '../shared/lib/router/utils/parse-path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the trailing slash of a path according to the `trailingSlash` option
|
||||||
|
* in `next.config.js`.
|
||||||
|
*/
|
||||||
|
export const normalizePathTrailingSlash = (path: string) => {
|
||||||
|
if (!path.startsWith('/') || process.env.__NEXT_MANUAL_TRAILING_SLASH) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname, query, hash } = parsePath(path)
|
||||||
|
if (process.env.__NEXT_TRAILING_SLASH) {
|
||||||
|
if (/\.[^/]+\/?$/.test(pathname)) {
|
||||||
|
return `${removeTrailingSlash(pathname)}${query}${hash}`
|
||||||
|
} else if (pathname.endsWith('/')) {
|
||||||
|
return `${pathname}${query}${hash}`
|
||||||
|
} else {
|
||||||
|
return `${pathname}/${query}${hash}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${removeTrailingSlash(pathname)}${query}${hash}`
|
||||||
|
}
|
||||||
210
client/page-loader.ts
Normal file
210
client/page-loader.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { RouteLoader } from './route-loader'
|
||||||
|
import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info'
|
||||||
|
import { addBasePath } from './add-base-path'
|
||||||
|
import { interpolateAs } from '../shared/lib/router/utils/interpolate-as'
|
||||||
|
import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route'
|
||||||
|
import { addLocale } from './add-locale'
|
||||||
|
import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic'
|
||||||
|
import { parseRelativeUrl } from '../shared/lib/router/utils/parse-relative-url'
|
||||||
|
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
|
||||||
|
import { createRouteLoader, getClientBuildManifest } from './route-loader'
|
||||||
|
import {
|
||||||
|
DEV_CLIENT_PAGES_MANIFEST,
|
||||||
|
DEV_CLIENT_MIDDLEWARE_MANIFEST,
|
||||||
|
TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST,
|
||||||
|
} from '../shared/lib/constants'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__DEV_MIDDLEWARE_MATCHERS?: MiddlewareMatcher[]
|
||||||
|
__DEV_PAGES_MANIFEST?: { pages: string[] }
|
||||||
|
__SSG_MANIFEST_CB?: () => void
|
||||||
|
__SSG_MANIFEST?: Set<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StyleSheetTuple = { href: string; text: string }
|
||||||
|
export type GoodPageCache = {
|
||||||
|
page: ComponentType
|
||||||
|
mod: any
|
||||||
|
styleSheets: StyleSheetTuple[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PageLoader {
|
||||||
|
private buildId: string
|
||||||
|
private assetPrefix: string
|
||||||
|
private promisedSsgManifest: Promise<Set<string>>
|
||||||
|
private promisedDevPagesManifest?: Promise<string[]>
|
||||||
|
private promisedMiddlewareMatchers?: Promise<MiddlewareMatcher[]>
|
||||||
|
|
||||||
|
public routeLoader: RouteLoader
|
||||||
|
|
||||||
|
constructor(buildId: string, assetPrefix: string) {
|
||||||
|
this.routeLoader = createRouteLoader(assetPrefix)
|
||||||
|
|
||||||
|
this.buildId = buildId
|
||||||
|
this.assetPrefix = assetPrefix
|
||||||
|
|
||||||
|
this.promisedSsgManifest = new Promise((resolve) => {
|
||||||
|
if (window.__SSG_MANIFEST) {
|
||||||
|
resolve(window.__SSG_MANIFEST)
|
||||||
|
} else {
|
||||||
|
window.__SSG_MANIFEST_CB = () => {
|
||||||
|
resolve(window.__SSG_MANIFEST!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getPageList() {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return getClientBuildManifest().then((manifest) => manifest.sortedPages)
|
||||||
|
} else {
|
||||||
|
if (window.__DEV_PAGES_MANIFEST) {
|
||||||
|
return window.__DEV_PAGES_MANIFEST.pages
|
||||||
|
} else {
|
||||||
|
this.promisedDevPagesManifest ||= fetch(
|
||||||
|
`${this.assetPrefix}/_next/static/development/${DEV_CLIENT_PAGES_MANIFEST}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((manifest: { pages: string[] }) => {
|
||||||
|
window.__DEV_PAGES_MANIFEST = manifest
|
||||||
|
return manifest.pages
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(`Failed to fetch devPagesManifest:`, err)
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch _devPagesManifest.json. Is something blocking that network request?\n` +
|
||||||
|
'Read more: https://nextjs.org/docs/messages/failed-to-fetch-devpagesmanifest'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return this.promisedDevPagesManifest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMiddleware() {
|
||||||
|
// Webpack production
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.__NEXT_MIDDLEWARE_MATCHERS
|
||||||
|
) {
|
||||||
|
const middlewareMatchers = process.env.__NEXT_MIDDLEWARE_MATCHERS
|
||||||
|
window.__MIDDLEWARE_MATCHERS = middlewareMatchers
|
||||||
|
? (middlewareMatchers as any as MiddlewareMatcher[])
|
||||||
|
: undefined
|
||||||
|
return window.__MIDDLEWARE_MATCHERS
|
||||||
|
// Turbopack production
|
||||||
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (window.__MIDDLEWARE_MATCHERS) {
|
||||||
|
return window.__MIDDLEWARE_MATCHERS
|
||||||
|
} else {
|
||||||
|
if (!this.promisedMiddlewareMatchers) {
|
||||||
|
// TODO: Decide what should happen when fetching fails instead of asserting
|
||||||
|
// @ts-ignore
|
||||||
|
this.promisedMiddlewareMatchers = fetch(
|
||||||
|
`${this.assetPrefix}/_next/static/${this.buildId}/${TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((matchers: MiddlewareMatcher[]) => {
|
||||||
|
window.__MIDDLEWARE_MATCHERS = matchers
|
||||||
|
return matchers
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(`Failed to fetch _devMiddlewareManifest`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// TODO Remove this assertion as this could be undefined
|
||||||
|
return this.promisedMiddlewareMatchers!
|
||||||
|
}
|
||||||
|
// Development both Turbopack and Webpack
|
||||||
|
} else {
|
||||||
|
if (window.__DEV_MIDDLEWARE_MATCHERS) {
|
||||||
|
return window.__DEV_MIDDLEWARE_MATCHERS
|
||||||
|
} else {
|
||||||
|
if (!this.promisedMiddlewareMatchers) {
|
||||||
|
// TODO: Decide what should happen when fetching fails instead of asserting
|
||||||
|
// @ts-ignore
|
||||||
|
this.promisedMiddlewareMatchers = fetch(
|
||||||
|
`${this.assetPrefix}/_next/static/${this.buildId}/${DEV_CLIENT_MIDDLEWARE_MANIFEST}`,
|
||||||
|
{ credentials: 'same-origin' }
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((matchers: MiddlewareMatcher[]) => {
|
||||||
|
window.__DEV_MIDDLEWARE_MATCHERS = matchers
|
||||||
|
return matchers
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(`Failed to fetch _devMiddlewareManifest`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// TODO Remove this assertion as this could be undefined
|
||||||
|
return this.promisedMiddlewareMatchers!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDataHref(params: {
|
||||||
|
asPath: string
|
||||||
|
href: string
|
||||||
|
locale?: string | false
|
||||||
|
skipInterpolation?: boolean
|
||||||
|
}): string {
|
||||||
|
const { asPath, href, locale } = params
|
||||||
|
const { pathname: hrefPathname, query, search } = parseRelativeUrl(href)
|
||||||
|
const { pathname: asPathname } = parseRelativeUrl(asPath)
|
||||||
|
const route = removeTrailingSlash(hrefPathname)
|
||||||
|
if (route[0] !== '/') {
|
||||||
|
throw new Error(`Route name should start with a "/", got "${route}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHrefForSlug = (path: string) => {
|
||||||
|
const dataRoute = getAssetPathFromRoute(
|
||||||
|
removeTrailingSlash(addLocale(path, locale)),
|
||||||
|
'.json'
|
||||||
|
)
|
||||||
|
return addBasePath(
|
||||||
|
`/_next/data/${this.buildId}${dataRoute}${search}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHrefForSlug(
|
||||||
|
params.skipInterpolation
|
||||||
|
? asPathname
|
||||||
|
: isDynamicRoute(route)
|
||||||
|
? interpolateAs(hrefPathname, asPathname, query).result
|
||||||
|
: route
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSsg(
|
||||||
|
/** the route (file-system path) */
|
||||||
|
route: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.promisedSsgManifest.then((manifest) => manifest.has(route))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPage(route: string): Promise<GoodPageCache> {
|
||||||
|
return this.routeLoader.loadRoute(route).then((res) => {
|
||||||
|
if ('component' in res) {
|
||||||
|
return {
|
||||||
|
page: res.component,
|
||||||
|
mod: res.exports,
|
||||||
|
styleSheets: res.styles.map((o) => ({
|
||||||
|
href: o.href,
|
||||||
|
text: o.content,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw res.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch(route: string): Promise<void> {
|
||||||
|
return this.routeLoader.prefetch(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
client/remove-base-path.ts
Normal file
18
client/remove-base-path.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { hasBasePath } from './has-base-path'
|
||||||
|
|
||||||
|
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||||
|
|
||||||
|
export function removeBasePath(path: string): string {
|
||||||
|
if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) {
|
||||||
|
if (!hasBasePath(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't trim the basePath if it has zero length!
|
||||||
|
if (basePath.length === 0) return path
|
||||||
|
|
||||||
|
path = path.slice(basePath.length)
|
||||||
|
if (!path.startsWith('/')) path = `/${path}`
|
||||||
|
return path
|
||||||
|
}
|
||||||
18
client/remove-locale.ts
Normal file
18
client/remove-locale.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { parsePath } from '../shared/lib/router/utils/parse-path'
|
||||||
|
|
||||||
|
export function removeLocale(path: string, locale?: string) {
|
||||||
|
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||||
|
const { pathname } = parsePath(path)
|
||||||
|
const pathLower = pathname.toLowerCase()
|
||||||
|
const localeLower = locale?.toLowerCase()
|
||||||
|
|
||||||
|
return locale &&
|
||||||
|
(pathLower.startsWith(`/${localeLower}/`) ||
|
||||||
|
pathLower === `/${localeLower}`)
|
||||||
|
? `${pathname.length === locale.length + 1 ? `/` : ``}${path.slice(
|
||||||
|
locale.length + 1
|
||||||
|
)}`
|
||||||
|
: path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
23
client/request-idle-callback.ts
Normal file
23
client/request-idle-callback.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const requestIdleCallback =
|
||||||
|
(typeof self !== 'undefined' &&
|
||||||
|
self.requestIdleCallback &&
|
||||||
|
self.requestIdleCallback.bind(window)) ||
|
||||||
|
function (cb: IdleRequestCallback): number {
|
||||||
|
let start = Date.now()
|
||||||
|
return self.setTimeout(function () {
|
||||||
|
cb({
|
||||||
|
didTimeout: false,
|
||||||
|
timeRemaining: function () {
|
||||||
|
return Math.max(0, 50 - (Date.now() - start))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cancelIdleCallback =
|
||||||
|
(typeof self !== 'undefined' &&
|
||||||
|
self.cancelIdleCallback &&
|
||||||
|
self.cancelIdleCallback.bind(window)) ||
|
||||||
|
function (id: number) {
|
||||||
|
return clearTimeout(id)
|
||||||
|
}
|
||||||
138
client/resolve-href.ts
Normal file
138
client/resolve-href.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { NextRouter, Url } from '../shared/lib/router/router'
|
||||||
|
|
||||||
|
import { searchParamsToUrlQuery } from '../shared/lib/router/utils/querystring'
|
||||||
|
import { formatWithValidation } from '../shared/lib/router/utils/format-url'
|
||||||
|
import { omit } from '../shared/lib/router/utils/omit'
|
||||||
|
import { normalizeRepeatedSlashes } from '../shared/lib/utils'
|
||||||
|
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||||
|
import { isLocalURL } from '../shared/lib/router/utils/is-local-url'
|
||||||
|
import { isDynamicRoute } from '../shared/lib/router/utils'
|
||||||
|
import { interpolateAs } from '../shared/lib/router/utils/interpolate-as'
|
||||||
|
import { getRouteRegex } from '../shared/lib/router/utils/route-regex'
|
||||||
|
import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a given hyperlink with a certain router state (basePath not included).
|
||||||
|
* Preserves absolute urls.
|
||||||
|
*/
|
||||||
|
export function resolveHref(
|
||||||
|
router: NextRouter,
|
||||||
|
href: Url,
|
||||||
|
resolveAs: true
|
||||||
|
): [string, string] | [string]
|
||||||
|
export function resolveHref(
|
||||||
|
router: NextRouter,
|
||||||
|
href: Url,
|
||||||
|
resolveAs?: false
|
||||||
|
): string
|
||||||
|
export function resolveHref(
|
||||||
|
router: NextRouter,
|
||||||
|
href: Url,
|
||||||
|
resolveAs?: boolean
|
||||||
|
): [string, string] | [string] | string {
|
||||||
|
// we use a dummy base url for relative urls
|
||||||
|
let base: URL
|
||||||
|
let urlAsString = typeof href === 'string' ? href : formatWithValidation(href)
|
||||||
|
|
||||||
|
// repeated slashes and backslashes in the URL are considered
|
||||||
|
// invalid and will never match a Next.js page/file
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1
|
||||||
|
const urlProtoMatch = urlAsString.match(/^[a-z][a-z0-9+.-]*:\/\//i)
|
||||||
|
const urlAsStringNoProto = urlProtoMatch
|
||||||
|
? urlAsString.slice(urlProtoMatch[0].length)
|
||||||
|
: urlAsString
|
||||||
|
|
||||||
|
const urlParts = urlAsStringNoProto.split('?', 1)
|
||||||
|
|
||||||
|
if ((urlParts[0] || '').match(/(\/\/|\\)/)) {
|
||||||
|
console.error(
|
||||||
|
`Invalid href '${urlAsString}' passed to next/router in page: '${router.pathname}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`
|
||||||
|
)
|
||||||
|
const normalizedUrl = normalizeRepeatedSlashes(urlAsStringNoProto)
|
||||||
|
urlAsString = (urlProtoMatch ? urlProtoMatch[0] : '') + normalizedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return because it cannot be routed by the Next.js router
|
||||||
|
if (!isLocalURL(urlAsString)) {
|
||||||
|
return (resolveAs ? [urlAsString] : urlAsString) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let baseBase = urlAsString.startsWith('#') ? router.asPath : router.pathname
|
||||||
|
|
||||||
|
// If the provided href is only a query string, it is safer to use the asPath
|
||||||
|
// considering rewrites.
|
||||||
|
if (urlAsString.startsWith('?')) {
|
||||||
|
baseBase = router.asPath
|
||||||
|
|
||||||
|
// However, if is a dynamic route, we need to use the pathname to preserve the
|
||||||
|
// query interpolation and rewrites (router.pathname will look like "/[slug]").
|
||||||
|
if (isDynamicRoute(router.pathname)) {
|
||||||
|
baseBase = router.pathname
|
||||||
|
|
||||||
|
const routeRegex = getRouteRegex(router.pathname)
|
||||||
|
const match = getRouteMatcher(routeRegex)(router.asPath)
|
||||||
|
|
||||||
|
// For dynamic routes, if asPath doesn't match the pathname regex, it is a rewritten path.
|
||||||
|
// In this case, should use asPath to preserve the current URL.
|
||||||
|
if (!match) {
|
||||||
|
baseBase = router.asPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: There is an edge case where the pathname is dynamic, and also a rewrite path to the same segment.
|
||||||
|
// E.g. in "/[slug]" path, rewrite "/foo" -> "/bar"
|
||||||
|
|
||||||
|
// In this case, it will be treated as a non-rewritten path and possibly interpolate the query string.
|
||||||
|
// E.g., "/any?slug=foo" will become the content of "/foo", not rewritten as "/bar"
|
||||||
|
|
||||||
|
// This is currently a trade-off of not resolving rewrite paths on every Router/Link call,
|
||||||
|
// but using a lighter route regex pattern check.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base = new URL(baseBase, 'http://n')
|
||||||
|
} catch (_) {
|
||||||
|
// fallback to / for invalid asPath values e.g. //
|
||||||
|
base = new URL('/', 'http://n')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalUrl = new URL(urlAsString, base)
|
||||||
|
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
|
||||||
|
let interpolatedAs = ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDynamicRoute(finalUrl.pathname) &&
|
||||||
|
finalUrl.searchParams &&
|
||||||
|
resolveAs
|
||||||
|
) {
|
||||||
|
const query = searchParamsToUrlQuery(finalUrl.searchParams)
|
||||||
|
|
||||||
|
const { result, params } = interpolateAs(
|
||||||
|
finalUrl.pathname,
|
||||||
|
finalUrl.pathname,
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
interpolatedAs = formatWithValidation({
|
||||||
|
pathname: result,
|
||||||
|
hash: finalUrl.hash,
|
||||||
|
query: omit(query, params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the origin didn't change, it means we received a relative href
|
||||||
|
const resolvedHref =
|
||||||
|
finalUrl.origin === base.origin
|
||||||
|
? finalUrl.href.slice(finalUrl.origin.length)
|
||||||
|
: finalUrl.href
|
||||||
|
|
||||||
|
return resolveAs
|
||||||
|
? [resolvedHref, interpolatedAs || resolvedHref]
|
||||||
|
: resolvedHref
|
||||||
|
} catch (_) {
|
||||||
|
return resolveAs ? [urlAsString] : urlAsString
|
||||||
|
}
|
||||||
|
}
|
||||||
65
client/route-announcer.tsx
Normal file
65
client/route-announcer.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from './router'
|
||||||
|
|
||||||
|
const nextjsRouteAnnouncerStyles: React.CSSProperties = {
|
||||||
|
border: 0,
|
||||||
|
clip: 'rect(0 0 0 0)',
|
||||||
|
height: '1px',
|
||||||
|
margin: '-1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
width: '1px',
|
||||||
|
|
||||||
|
// https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
wordWrap: 'normal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RouteAnnouncer = () => {
|
||||||
|
const { asPath } = useRouter()
|
||||||
|
const [routeAnnouncement, setRouteAnnouncement] = React.useState('')
|
||||||
|
|
||||||
|
// Only announce the path change, but not for the first load because screen
|
||||||
|
// reader will do that automatically.
|
||||||
|
const previouslyLoadedPath = React.useRef(asPath)
|
||||||
|
|
||||||
|
// Every time the path changes, announce the new page’s title following this
|
||||||
|
// priority: first the document title (from head), otherwise the first h1, or
|
||||||
|
// if none of these exist, then the pathname from the URL. This methodology is
|
||||||
|
// inspired by Marcy Sutton’s accessible client routing user testing. More
|
||||||
|
// information can be found here:
|
||||||
|
// https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
|
||||||
|
React.useEffect(
|
||||||
|
() => {
|
||||||
|
// If the path hasn't change, we do nothing.
|
||||||
|
if (previouslyLoadedPath.current === asPath) return
|
||||||
|
previouslyLoadedPath.current = asPath
|
||||||
|
|
||||||
|
if (document.title) {
|
||||||
|
setRouteAnnouncement(document.title)
|
||||||
|
} else {
|
||||||
|
const pageHeader = document.querySelector('h1')
|
||||||
|
const content = pageHeader?.innerText ?? pageHeader?.textContent
|
||||||
|
|
||||||
|
setRouteAnnouncement(content || asPath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// TODO: switch to pathname + query object of dynamic route requirements
|
||||||
|
[asPath]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
aria-live="assertive" // Make the announcement immediately.
|
||||||
|
id="__next-route-announcer__"
|
||||||
|
role="alert"
|
||||||
|
style={nextjsRouteAnnouncerStyles}
|
||||||
|
>
|
||||||
|
{routeAnnouncement}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RouteAnnouncer
|
||||||
453
client/route-loader.ts
Normal file
453
client/route-loader.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info'
|
||||||
|
import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route'
|
||||||
|
import { __unsafeCreateTrustedScriptURL } from './trusted-types'
|
||||||
|
import { requestIdleCallback } from './request-idle-callback'
|
||||||
|
import { getDeploymentIdQueryOrEmptyString } from '../build/deployment-id'
|
||||||
|
import { encodeURIPath } from '../shared/lib/encode-uri-path'
|
||||||
|
|
||||||
|
// 3.8s was arbitrarily chosen as it's what https://web.dev/interactive
|
||||||
|
// considers as "Good" time-to-interactive. We must assume something went
|
||||||
|
// wrong beyond this point, and then fall-back to a full page transition to
|
||||||
|
// show the user something of value.
|
||||||
|
const MS_MAX_IDLE_DELAY = 3800
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__BUILD_MANIFEST?: Record<string, string[]>
|
||||||
|
__BUILD_MANIFEST_CB?: Function
|
||||||
|
__MIDDLEWARE_MATCHERS?: MiddlewareMatcher[]
|
||||||
|
__MIDDLEWARE_MANIFEST_CB?: Function
|
||||||
|
__REACT_LOADABLE_MANIFEST?: any
|
||||||
|
__DYNAMIC_CSS_MANIFEST?: any
|
||||||
|
__RSC_MANIFEST?: any
|
||||||
|
__RSC_SERVER_MANIFEST?: any
|
||||||
|
__NEXT_FONT_MANIFEST?: any
|
||||||
|
__SUBRESOURCE_INTEGRITY_MANIFEST?: string
|
||||||
|
__INTERCEPTION_ROUTE_REWRITE_MANIFEST?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedEntrypointSuccess {
|
||||||
|
component: ComponentType
|
||||||
|
exports: any
|
||||||
|
}
|
||||||
|
interface LoadedEntrypointFailure {
|
||||||
|
error: unknown
|
||||||
|
}
|
||||||
|
type RouteEntrypoint = LoadedEntrypointSuccess | LoadedEntrypointFailure
|
||||||
|
|
||||||
|
interface RouteStyleSheet {
|
||||||
|
href: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedRouteSuccess extends LoadedEntrypointSuccess {
|
||||||
|
styles: RouteStyleSheet[]
|
||||||
|
}
|
||||||
|
interface LoadedRouteFailure {
|
||||||
|
error: unknown
|
||||||
|
}
|
||||||
|
type RouteLoaderEntry = LoadedRouteSuccess | LoadedRouteFailure
|
||||||
|
|
||||||
|
interface Future<V> {
|
||||||
|
resolve: (entrypoint: V) => void
|
||||||
|
future: Promise<V>
|
||||||
|
}
|
||||||
|
function withFuture<T extends object>(
|
||||||
|
key: string,
|
||||||
|
map: Map<string, Future<T> | T>,
|
||||||
|
generator?: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
let entry = map.get(key)
|
||||||
|
if (entry) {
|
||||||
|
if ('future' in entry) {
|
||||||
|
return entry.future
|
||||||
|
}
|
||||||
|
return Promise.resolve(entry)
|
||||||
|
}
|
||||||
|
let resolver: (entrypoint: T) => void
|
||||||
|
const prom: Promise<T> = new Promise<T>((resolve) => {
|
||||||
|
resolver = resolve
|
||||||
|
})
|
||||||
|
map.set(key, { resolve: resolver!, future: prom })
|
||||||
|
return generator
|
||||||
|
? generator()
|
||||||
|
.then((value) => {
|
||||||
|
resolver(value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
map.delete(key)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
: prom
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteLoader {
|
||||||
|
whenEntrypoint(route: string): Promise<RouteEntrypoint>
|
||||||
|
onEntrypoint(route: string, execute: () => unknown): void
|
||||||
|
loadRoute(route: string, prefetch?: boolean): Promise<RouteLoaderEntry>
|
||||||
|
prefetch(route: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_LOAD_ERROR = Symbol('ASSET_LOAD_ERROR')
|
||||||
|
// TODO: unexport
|
||||||
|
export function markAssetError(err: Error): Error {
|
||||||
|
return Object.defineProperty(err, ASSET_LOAD_ERROR, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAssetError(err?: Error): boolean | undefined {
|
||||||
|
return err && ASSET_LOAD_ERROR in err
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPrefetch(link?: HTMLLinkElement): boolean {
|
||||||
|
try {
|
||||||
|
link = document.createElement('link')
|
||||||
|
return (
|
||||||
|
// detect IE11 since it supports prefetch but isn't detected
|
||||||
|
// with relList.support
|
||||||
|
(!!window.MSInputMethodContext && !!(document as any).documentMode) ||
|
||||||
|
link.relList.supports('prefetch')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPrefetch: boolean = hasPrefetch()
|
||||||
|
|
||||||
|
const getAssetQueryString = () => {
|
||||||
|
return getDeploymentIdQueryOrEmptyString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchViaDom(
|
||||||
|
href: string,
|
||||||
|
as: string,
|
||||||
|
link?: HTMLLinkElement
|
||||||
|
): Promise<any> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const selector = `
|
||||||
|
link[rel="prefetch"][href^="${href}"],
|
||||||
|
link[rel="preload"][href^="${href}"],
|
||||||
|
script[src^="${href}"]`
|
||||||
|
if (document.querySelector(selector)) {
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
link = document.createElement('link')
|
||||||
|
|
||||||
|
// The order of property assignment here is intentional:
|
||||||
|
if (as) link!.as = as
|
||||||
|
link!.rel = `prefetch`
|
||||||
|
link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
|
||||||
|
link!.onload = resolve as any
|
||||||
|
link!.onerror = () =>
|
||||||
|
reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))
|
||||||
|
|
||||||
|
// `href` should always be last:
|
||||||
|
link!.href = href
|
||||||
|
|
||||||
|
document.head.appendChild(link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendScript(
|
||||||
|
src: TrustedScriptURL | string,
|
||||||
|
script?: HTMLScriptElement
|
||||||
|
): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
script = document.createElement('script')
|
||||||
|
|
||||||
|
// The order of property assignment here is intentional.
|
||||||
|
// 1. Setup success/failure hooks in case the browser synchronously
|
||||||
|
// executes when `src` is set.
|
||||||
|
script.onload = resolve
|
||||||
|
script.onerror = () =>
|
||||||
|
reject(markAssetError(new Error(`Failed to load script: ${src}`)))
|
||||||
|
|
||||||
|
// 2. Configure the cross-origin attribute before setting `src` in case the
|
||||||
|
// browser begins to fetch.
|
||||||
|
script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
|
||||||
|
|
||||||
|
// 3. Finally, set the source and inject into the DOM in case the child
|
||||||
|
// must be appended for fetching to start.
|
||||||
|
script.src = src as string
|
||||||
|
document.body.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// We wait for pages to be built in dev before we start the route transition
|
||||||
|
// timeout to prevent an un-necessary hard navigation in development.
|
||||||
|
let devBuildPromise: Promise<void> | undefined
|
||||||
|
|
||||||
|
// Resolve a promise that times out after given amount of milliseconds.
|
||||||
|
function resolvePromiseWithTimeout<T>(
|
||||||
|
p: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
err: Error
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
p.then((r) => {
|
||||||
|
// Resolved, cancel the timeout
|
||||||
|
cancelled = true
|
||||||
|
resolve(r)
|
||||||
|
}).catch(reject)
|
||||||
|
|
||||||
|
// We wrap these checks separately for better dead-code elimination in
|
||||||
|
// production bundles.
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
;(devBuildPromise || Promise.resolve()).then(() => {
|
||||||
|
requestIdleCallback(() =>
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}, ms)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
requestIdleCallback(() =>
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}, ms)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: stop exporting or cache the failure
|
||||||
|
// It'd be best to stop exporting this. It's an implementation detail. We're
|
||||||
|
// only exporting it for backwards compatibility with the `page-loader`.
|
||||||
|
// Only cache this response as a last resort if we cannot eliminate all other
|
||||||
|
// code branches that use the Build Manifest Callback and push them through
|
||||||
|
// the Route Loader interface.
|
||||||
|
export function getClientBuildManifest() {
|
||||||
|
if (self.__BUILD_MANIFEST) {
|
||||||
|
return Promise.resolve(self.__BUILD_MANIFEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBuildManifest = new Promise<Record<string, string[]>>((resolve) => {
|
||||||
|
// Mandatory because this is not concurrent safe:
|
||||||
|
const cb = self.__BUILD_MANIFEST_CB
|
||||||
|
self.__BUILD_MANIFEST_CB = () => {
|
||||||
|
resolve(self.__BUILD_MANIFEST!)
|
||||||
|
cb && cb()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return resolvePromiseWithTimeout(
|
||||||
|
onBuildManifest,
|
||||||
|
MS_MAX_IDLE_DELAY,
|
||||||
|
markAssetError(new Error('Failed to load client build manifest'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteFiles {
|
||||||
|
scripts: (TrustedScriptURL | string)[]
|
||||||
|
css: string[]
|
||||||
|
}
|
||||||
|
function getFilesForRoute(
|
||||||
|
assetPrefix: string,
|
||||||
|
route: string
|
||||||
|
): Promise<RouteFiles> {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const scriptUrl =
|
||||||
|
assetPrefix +
|
||||||
|
'/_next/static/chunks/pages' +
|
||||||
|
encodeURIPath(getAssetPathFromRoute(route, '.js')) +
|
||||||
|
getAssetQueryString()
|
||||||
|
return Promise.resolve({
|
||||||
|
scripts: [__unsafeCreateTrustedScriptURL(scriptUrl)],
|
||||||
|
// Styles are handled by `style-loader` in development:
|
||||||
|
css: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return getClientBuildManifest().then((manifest) => {
|
||||||
|
if (!(route in manifest)) {
|
||||||
|
throw markAssetError(new Error(`Failed to lookup route: ${route}`))
|
||||||
|
}
|
||||||
|
const allFiles = manifest[route].map(
|
||||||
|
(entry) => assetPrefix + '/_next/' + encodeURIPath(entry)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
scripts: allFiles
|
||||||
|
.filter((v) => v.endsWith('.js'))
|
||||||
|
.map((v) => __unsafeCreateTrustedScriptURL(v) + getAssetQueryString()),
|
||||||
|
css: allFiles
|
||||||
|
.filter((v) => v.endsWith('.css'))
|
||||||
|
.map((v) => v + getAssetQueryString()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteLoader(assetPrefix: string): RouteLoader {
|
||||||
|
const entrypoints: Map<string, Future<RouteEntrypoint> | RouteEntrypoint> =
|
||||||
|
new Map()
|
||||||
|
const loadedScripts: Map<string, Promise<unknown>> = new Map()
|
||||||
|
const styleSheets: Map<string, Promise<RouteStyleSheet>> = new Map()
|
||||||
|
const routes: Map<string, Future<RouteLoaderEntry> | RouteLoaderEntry> =
|
||||||
|
new Map()
|
||||||
|
|
||||||
|
function maybeExecuteScript(
|
||||||
|
src: TrustedScriptURL | string
|
||||||
|
): Promise<unknown> {
|
||||||
|
// With HMR we might need to "reload" scripts when they are
|
||||||
|
// disposed and readded. Executing scripts twice has no functional
|
||||||
|
// differences
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
let prom: Promise<unknown> | undefined = loadedScripts.get(src.toString())
|
||||||
|
if (prom) {
|
||||||
|
return prom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip executing script if it's already in the DOM:
|
||||||
|
if (document.querySelector(`script[src^="${src}"]`)) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedScripts.set(src.toString(), (prom = appendScript(src)))
|
||||||
|
return prom
|
||||||
|
} else {
|
||||||
|
return appendScript(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchStyleSheet(href: string): Promise<RouteStyleSheet> {
|
||||||
|
let prom: Promise<RouteStyleSheet> | undefined = styleSheets.get(href)
|
||||||
|
if (prom) {
|
||||||
|
return prom
|
||||||
|
}
|
||||||
|
|
||||||
|
styleSheets.set(
|
||||||
|
href,
|
||||||
|
(prom = fetch(href, { credentials: 'same-origin' })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load stylesheet: ${href}`)
|
||||||
|
}
|
||||||
|
return res.text().then((text) => ({ href: href, content: text }))
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
throw markAssetError(err)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return prom
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
whenEntrypoint(route: string) {
|
||||||
|
return withFuture(route, entrypoints)
|
||||||
|
},
|
||||||
|
onEntrypoint(route: string, execute: undefined | (() => unknown)) {
|
||||||
|
;(execute
|
||||||
|
? Promise.resolve()
|
||||||
|
.then(() => execute())
|
||||||
|
.then(
|
||||||
|
(exports: any) => ({
|
||||||
|
component: (exports && exports.default) || exports,
|
||||||
|
exports: exports,
|
||||||
|
}),
|
||||||
|
(err) => ({ error: err })
|
||||||
|
)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
|
).then((input: RouteEntrypoint | undefined) => {
|
||||||
|
const old = entrypoints.get(route)
|
||||||
|
if (old && 'resolve' in old) {
|
||||||
|
if (input) {
|
||||||
|
entrypoints.set(route, input)
|
||||||
|
old.resolve(input)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (input) {
|
||||||
|
entrypoints.set(route, input)
|
||||||
|
} else {
|
||||||
|
entrypoints.delete(route)
|
||||||
|
}
|
||||||
|
// when this entrypoint has been resolved before
|
||||||
|
// the route is outdated and we want to invalidate
|
||||||
|
// this cache entry
|
||||||
|
routes.delete(route)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadRoute(route: string, prefetch?: boolean) {
|
||||||
|
return withFuture<RouteLoaderEntry>(route, routes, () => {
|
||||||
|
let devBuildPromiseResolve: () => void
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
devBuildPromise = new Promise<void>((resolve) => {
|
||||||
|
devBuildPromiseResolve = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePromiseWithTimeout(
|
||||||
|
getFilesForRoute(assetPrefix, route)
|
||||||
|
.then(({ scripts, css }) => {
|
||||||
|
return Promise.all([
|
||||||
|
entrypoints.has(route)
|
||||||
|
? []
|
||||||
|
: Promise.all(scripts.map(maybeExecuteScript)),
|
||||||
|
Promise.all(css.map(fetchStyleSheet)),
|
||||||
|
] as const)
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
return this.whenEntrypoint(route).then((entrypoint) => ({
|
||||||
|
entrypoint,
|
||||||
|
styles: res[1],
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
MS_MAX_IDLE_DELAY,
|
||||||
|
markAssetError(new Error(`Route did not complete loading: ${route}`))
|
||||||
|
)
|
||||||
|
.then(({ entrypoint, styles }) => {
|
||||||
|
const res: RouteLoaderEntry = Object.assign<
|
||||||
|
{ styles: RouteStyleSheet[] },
|
||||||
|
RouteEntrypoint
|
||||||
|
>({ styles: styles! }, entrypoint)
|
||||||
|
return 'error' in entrypoint ? entrypoint : res
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (prefetch) {
|
||||||
|
// we don't want to cache errors during prefetch
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { error: err }
|
||||||
|
})
|
||||||
|
.finally(() => devBuildPromiseResolve?.())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
prefetch(route: string): Promise<void> {
|
||||||
|
// https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
|
||||||
|
// License: Apache 2.0
|
||||||
|
let cn
|
||||||
|
if ((cn = (navigator as any).connection)) {
|
||||||
|
// Don't prefetch if using 2G or if Save-Data is enabled.
|
||||||
|
if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
|
||||||
|
}
|
||||||
|
return getFilesForRoute(assetPrefix, route)
|
||||||
|
.then((output) =>
|
||||||
|
Promise.all(
|
||||||
|
canPrefetch
|
||||||
|
? output.scripts.map((script) =>
|
||||||
|
prefetchViaDom(script.toString(), 'script')
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
|
||||||
|
})
|
||||||
|
.catch(
|
||||||
|
// swallow prefetch errors
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
195
client/router.ts
Normal file
195
client/router.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/* global window */
|
||||||
|
import React from 'react'
|
||||||
|
import Router from '../shared/lib/router/router'
|
||||||
|
import type { NextRouter } from '../shared/lib/router/router'
|
||||||
|
import { RouterContext } from '../shared/lib/router-context.shared-runtime'
|
||||||
|
import isError from '../lib/is-error'
|
||||||
|
|
||||||
|
type SingletonRouterBase = {
|
||||||
|
router: Router | null
|
||||||
|
readyCallbacks: Array<() => any>
|
||||||
|
ready(cb: () => any): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Router }
|
||||||
|
|
||||||
|
export type { NextRouter }
|
||||||
|
|
||||||
|
export type SingletonRouter = SingletonRouterBase & NextRouter
|
||||||
|
|
||||||
|
const singletonRouter: SingletonRouterBase = {
|
||||||
|
router: null, // holds the actual router instance
|
||||||
|
readyCallbacks: [],
|
||||||
|
ready(callback: () => void) {
|
||||||
|
if (this.router) return callback()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
this.readyCallbacks.push(callback)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create public properties and methods of the router in the singletonRouter
|
||||||
|
const urlPropertyFields = [
|
||||||
|
'pathname',
|
||||||
|
'route',
|
||||||
|
'query',
|
||||||
|
'asPath',
|
||||||
|
'components',
|
||||||
|
'isFallback',
|
||||||
|
'basePath',
|
||||||
|
'locale',
|
||||||
|
'locales',
|
||||||
|
'defaultLocale',
|
||||||
|
'isReady',
|
||||||
|
'isPreview',
|
||||||
|
'isLocaleDomain',
|
||||||
|
'domainLocales',
|
||||||
|
] as const
|
||||||
|
const routerEvents = [
|
||||||
|
'routeChangeStart',
|
||||||
|
'beforeHistoryChange',
|
||||||
|
'routeChangeComplete',
|
||||||
|
'routeChangeError',
|
||||||
|
'hashChangeStart',
|
||||||
|
'hashChangeComplete',
|
||||||
|
] as const
|
||||||
|
export type RouterEvent = (typeof routerEvents)[number]
|
||||||
|
|
||||||
|
const coreMethodFields = [
|
||||||
|
'push',
|
||||||
|
'replace',
|
||||||
|
'reload',
|
||||||
|
'back',
|
||||||
|
'prefetch',
|
||||||
|
'beforePopState',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Events is a static property on the router, the router doesn't have to be initialized to use it
|
||||||
|
Object.defineProperty(singletonRouter, 'events', {
|
||||||
|
get() {
|
||||||
|
return Router.events
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function getRouter(): Router {
|
||||||
|
if (!singletonRouter.router) {
|
||||||
|
const message =
|
||||||
|
'No router instance found.\n' +
|
||||||
|
'You should only use "next/router" on the client side of your app.\n'
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return singletonRouter.router
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPropertyFields.forEach((field) => {
|
||||||
|
// Here we need to use Object.defineProperty because we need to return
|
||||||
|
// the property assigned to the actual router
|
||||||
|
// The value might get changed as we change routes and this is the
|
||||||
|
// proper way to access it
|
||||||
|
Object.defineProperty(singletonRouter, field, {
|
||||||
|
get() {
|
||||||
|
const router = getRouter()
|
||||||
|
return router[field] as string
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
coreMethodFields.forEach((field) => {
|
||||||
|
// We don't really know the types here, so we add them later instead
|
||||||
|
;(singletonRouter as any)[field] = (...args: any[]) => {
|
||||||
|
const router = getRouter() as any
|
||||||
|
return router[field](...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
routerEvents.forEach((event) => {
|
||||||
|
singletonRouter.ready(() => {
|
||||||
|
Router.events.on(event, (...args) => {
|
||||||
|
const eventField = `on${event.charAt(0).toUpperCase()}${event.substring(
|
||||||
|
1
|
||||||
|
)}`
|
||||||
|
const _singletonRouter = singletonRouter as any
|
||||||
|
if (_singletonRouter[eventField]) {
|
||||||
|
try {
|
||||||
|
_singletonRouter[eventField](...args)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error when running the Router event: ${eventField}`)
|
||||||
|
console.error(
|
||||||
|
isError(err) ? `${err.message}\n${err.stack}` : err + ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Export the singletonRouter and this is the public API.
|
||||||
|
export default singletonRouter as SingletonRouter
|
||||||
|
|
||||||
|
// Reexport the withRouter HOC
|
||||||
|
export { default as withRouter } from './with-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook gives access the [router object](https://nextjs.org/docs/pages/api-reference/functions/use-router#router-object)
|
||||||
|
* inside the [Pages Router](https://nextjs.org/docs/pages/building-your-application).
|
||||||
|
*
|
||||||
|
* Read more: [Next.js Docs: `useRouter`](https://nextjs.org/docs/pages/api-reference/functions/use-router)
|
||||||
|
*/
|
||||||
|
export function useRouter(): NextRouter {
|
||||||
|
const router = React.useContext(RouterContext)
|
||||||
|
if (!router) {
|
||||||
|
throw new Error(
|
||||||
|
'NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a router and assign it as the singleton instance.
|
||||||
|
* This is used in client side when we are initializing the app.
|
||||||
|
* This should **not** be used inside the server.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function createRouter(
|
||||||
|
...args: ConstructorParameters<typeof Router>
|
||||||
|
): Router {
|
||||||
|
singletonRouter.router = new Router(...args)
|
||||||
|
singletonRouter.readyCallbacks.forEach((cb) => cb())
|
||||||
|
singletonRouter.readyCallbacks = []
|
||||||
|
|
||||||
|
return singletonRouter.router
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to create the `withRouter` router instance
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function makePublicRouterInstance(router: Router): NextRouter {
|
||||||
|
const scopedRouter = router as any
|
||||||
|
const instance = {} as any
|
||||||
|
|
||||||
|
for (const property of urlPropertyFields) {
|
||||||
|
if (typeof scopedRouter[property] === 'object') {
|
||||||
|
instance[property] = Object.assign(
|
||||||
|
Array.isArray(scopedRouter[property]) ? [] : {},
|
||||||
|
scopedRouter[property]
|
||||||
|
) // makes sure query is not stateful
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
instance[property] = scopedRouter[property]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events is a static property on the router, the router doesn't have to be initialized to use it
|
||||||
|
instance.events = Router.events
|
||||||
|
|
||||||
|
coreMethodFields.forEach((field) => {
|
||||||
|
instance[field] = (...args: any[]) => {
|
||||||
|
return scopedRouter[field](...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return instance
|
||||||
|
}
|
||||||
385
client/script.tsx
Normal file
385
client/script.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import React, { useEffect, useContext, useRef, type JSX } from 'react'
|
||||||
|
import type { ScriptHTMLAttributes } from 'react'
|
||||||
|
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
|
||||||
|
import { setAttributesFromProps } from './set-attributes-from-props'
|
||||||
|
import { requestIdleCallback } from './request-idle-callback'
|
||||||
|
|
||||||
|
const ScriptCache = new Map()
|
||||||
|
const LoadCache = new Set()
|
||||||
|
|
||||||
|
export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
|
||||||
|
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
|
||||||
|
id?: string
|
||||||
|
onLoad?: (e: any) => void
|
||||||
|
onReady?: () => void | null
|
||||||
|
onError?: (e: any) => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
stylesheets?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `ScriptProps` instead.
|
||||||
|
*/
|
||||||
|
export type Props = ScriptProps
|
||||||
|
|
||||||
|
const insertStylesheets = (stylesheets: string[]) => {
|
||||||
|
// Case 1: Styles for afterInteractive/lazyOnload with appDir injected via handleClientScriptLoad
|
||||||
|
//
|
||||||
|
// Using ReactDOM.preinit to feature detect appDir and inject styles
|
||||||
|
// Stylesheets might have already been loaded if initialized with Script component
|
||||||
|
// Re-inject styles here to handle scripts loaded via handleClientScriptLoad
|
||||||
|
// ReactDOM.preinit handles dedup and ensures the styles are loaded only once
|
||||||
|
if (ReactDOM.preinit) {
|
||||||
|
stylesheets.forEach((stylesheet: string) => {
|
||||||
|
ReactDOM.preinit(stylesheet, { as: 'style' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Styles for afterInteractive/lazyOnload with pages injected via handleClientScriptLoad
|
||||||
|
//
|
||||||
|
// We use this function to load styles when appdir is not detected
|
||||||
|
// TODO: Use React float APIs to load styles once available for pages dir
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
let head = document.head
|
||||||
|
stylesheets.forEach((stylesheet: string) => {
|
||||||
|
let link = document.createElement('link')
|
||||||
|
|
||||||
|
link.type = 'text/css'
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
link.href = stylesheet
|
||||||
|
|
||||||
|
head.appendChild(link)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadScript = (props: ScriptProps): void => {
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
id,
|
||||||
|
onLoad = () => {},
|
||||||
|
onReady = null,
|
||||||
|
dangerouslySetInnerHTML,
|
||||||
|
children = '',
|
||||||
|
strategy = 'afterInteractive',
|
||||||
|
onError,
|
||||||
|
stylesheets,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const cacheKey = id || src
|
||||||
|
|
||||||
|
// Script has already loaded
|
||||||
|
if (cacheKey && LoadCache.has(cacheKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contents of this script are already loading/loaded
|
||||||
|
if (ScriptCache.has(src)) {
|
||||||
|
LoadCache.add(cacheKey)
|
||||||
|
// It is possible that multiple `next/script` components all have same "src", but has different "onLoad"
|
||||||
|
// This is to make sure the same remote script will only load once, but "onLoad" are executed in order
|
||||||
|
ScriptCache.get(src).then(onLoad, onError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute after the script first loaded */
|
||||||
|
const afterLoad = () => {
|
||||||
|
// Run onReady for the first time after load event
|
||||||
|
if (onReady) {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
// add cacheKey to LoadCache when load successfully
|
||||||
|
LoadCache.add(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement('script')
|
||||||
|
|
||||||
|
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
el.addEventListener('load', function (e) {
|
||||||
|
resolve()
|
||||||
|
if (onLoad) {
|
||||||
|
onLoad.call(this, e)
|
||||||
|
}
|
||||||
|
afterLoad()
|
||||||
|
})
|
||||||
|
el.addEventListener('error', function (e) {
|
||||||
|
reject(e)
|
||||||
|
})
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (onError) {
|
||||||
|
onError(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dangerouslySetInnerHTML) {
|
||||||
|
// Casting since lib.dom.d.ts doesn't have TrustedHTML yet.
|
||||||
|
el.innerHTML = (dangerouslySetInnerHTML.__html as string) || ''
|
||||||
|
|
||||||
|
afterLoad()
|
||||||
|
} else if (children) {
|
||||||
|
el.textContent =
|
||||||
|
typeof children === 'string'
|
||||||
|
? children
|
||||||
|
: Array.isArray(children)
|
||||||
|
? children.join('')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
afterLoad()
|
||||||
|
} else if (src) {
|
||||||
|
el.src = src
|
||||||
|
// do not add cacheKey into LoadCache for remote script here
|
||||||
|
// cacheKey will be added to LoadCache when it is actually loaded (see loadPromise above)
|
||||||
|
|
||||||
|
ScriptCache.set(src, loadPromise)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributesFromProps(el, props)
|
||||||
|
|
||||||
|
if (strategy === 'worker') {
|
||||||
|
el.setAttribute('type', 'text/partytown')
|
||||||
|
}
|
||||||
|
|
||||||
|
el.setAttribute('data-nscript', strategy)
|
||||||
|
|
||||||
|
// Load styles associated with this script
|
||||||
|
if (stylesheets) {
|
||||||
|
insertStylesheets(stylesheets)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleClientScriptLoad(props: ScriptProps) {
|
||||||
|
const { strategy = 'afterInteractive' } = props
|
||||||
|
if (strategy === 'lazyOnload') {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
requestIdleCallback(() => loadScript(props))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
loadScript(props)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLazyScript(props: ScriptProps) {
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
requestIdleCallback(() => loadScript(props))
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
requestIdleCallback(() => loadScript(props))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBeforeInteractiveToCache() {
|
||||||
|
const scripts = [
|
||||||
|
...document.querySelectorAll('[data-nscript="beforeInteractive"]'),
|
||||||
|
...document.querySelectorAll('[data-nscript="beforePageRender"]'),
|
||||||
|
]
|
||||||
|
scripts.forEach((script) => {
|
||||||
|
const cacheKey = script.id || script.getAttribute('src')
|
||||||
|
LoadCache.add(cacheKey)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScriptLoader(scriptLoaderItems: ScriptProps[]) {
|
||||||
|
scriptLoaderItems.forEach(handleClientScriptLoad)
|
||||||
|
addBeforeInteractiveToCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a third-party scripts in an optimized way.
|
||||||
|
*
|
||||||
|
* Read more: [Next.js Docs: `next/script`](https://nextjs.org/docs/app/api-reference/components/script)
|
||||||
|
*/
|
||||||
|
function Script(props: ScriptProps): JSX.Element | null {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
src = '',
|
||||||
|
onLoad = () => {},
|
||||||
|
onReady = null,
|
||||||
|
strategy = 'afterInteractive',
|
||||||
|
onError,
|
||||||
|
stylesheets,
|
||||||
|
...restProps
|
||||||
|
} = props
|
||||||
|
|
||||||
|
// Context is available only during SSR
|
||||||
|
let { updateScripts, scripts, getIsSsr, appDir, nonce } =
|
||||||
|
useContext(HeadManagerContext)
|
||||||
|
|
||||||
|
// if a nonce is explicitly passed to the script tag, favor that over the automatic handling
|
||||||
|
nonce = restProps.nonce || nonce
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - First mount:
|
||||||
|
* 1. The useEffect for onReady executes
|
||||||
|
* 2. hasOnReadyEffectCalled.current is false, but the script hasn't loaded yet (not in LoadCache)
|
||||||
|
* onReady is skipped, set hasOnReadyEffectCalled.current to true
|
||||||
|
* 3. The useEffect for loadScript executes
|
||||||
|
* 4. hasLoadScriptEffectCalled.current is false, loadScript executes
|
||||||
|
* Once the script is loaded, the onLoad and onReady will be called by then
|
||||||
|
* [If strict mode is enabled / is wrapped in <OffScreen /> component]
|
||||||
|
* 5. The useEffect for onReady executes again
|
||||||
|
* 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped
|
||||||
|
* 7. The useEffect for loadScript executes again
|
||||||
|
* 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped
|
||||||
|
*
|
||||||
|
* - Second mount:
|
||||||
|
* 1. The useEffect for onReady executes
|
||||||
|
* 2. hasOnReadyEffectCalled.current is false, but the script has already loaded (found in LoadCache)
|
||||||
|
* onReady is called, set hasOnReadyEffectCalled.current to true
|
||||||
|
* 3. The useEffect for loadScript executes
|
||||||
|
* 4. The script is already loaded, loadScript bails out
|
||||||
|
* [If strict mode is enabled / is wrapped in <OffScreen /> component]
|
||||||
|
* 5. The useEffect for onReady executes again
|
||||||
|
* 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped
|
||||||
|
* 7. The useEffect for loadScript executes again
|
||||||
|
* 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped
|
||||||
|
*/
|
||||||
|
const hasOnReadyEffectCalled = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cacheKey = id || src
|
||||||
|
if (!hasOnReadyEffectCalled.current) {
|
||||||
|
// Run onReady if script has loaded before but component is re-mounted
|
||||||
|
if (onReady && cacheKey && LoadCache.has(cacheKey)) {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOnReadyEffectCalled.current = true
|
||||||
|
}
|
||||||
|
}, [onReady, id, src])
|
||||||
|
|
||||||
|
const hasLoadScriptEffectCalled = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLoadScriptEffectCalled.current) {
|
||||||
|
if (strategy === 'afterInteractive') {
|
||||||
|
loadScript(props)
|
||||||
|
} else if (strategy === 'lazyOnload') {
|
||||||
|
loadLazyScript(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLoadScriptEffectCalled.current = true
|
||||||
|
}
|
||||||
|
}, [props, strategy])
|
||||||
|
|
||||||
|
if (strategy === 'beforeInteractive' || strategy === 'worker') {
|
||||||
|
if (updateScripts) {
|
||||||
|
scripts[strategy] = (scripts[strategy] || []).concat([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
src,
|
||||||
|
onLoad,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
...restProps,
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
updateScripts(scripts)
|
||||||
|
} else if (getIsSsr && getIsSsr()) {
|
||||||
|
// Script has already loaded during SSR
|
||||||
|
LoadCache.add(id || src)
|
||||||
|
} else if (getIsSsr && !getIsSsr()) {
|
||||||
|
loadScript({
|
||||||
|
...props,
|
||||||
|
nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the app directory, we need React Float to preload these scripts.
|
||||||
|
if (appDir) {
|
||||||
|
// Injecting stylesheets here handles beforeInteractive and worker scripts correctly
|
||||||
|
// For other strategies injecting here ensures correct stylesheet order
|
||||||
|
// ReactDOM.preinit handles loading the styles in the correct order,
|
||||||
|
// also ensures the stylesheet is loaded only once and in a consistent manner
|
||||||
|
//
|
||||||
|
// Case 1: Styles for beforeInteractive/worker with appDir - handled here
|
||||||
|
// Case 2: Styles for beforeInteractive/worker with pages dir - Not handled yet
|
||||||
|
// Case 3: Styles for afterInteractive/lazyOnload with appDir - handled here
|
||||||
|
// Case 4: Styles for afterInteractive/lazyOnload with pages dir - handled in insertStylesheets function
|
||||||
|
if (stylesheets) {
|
||||||
|
stylesheets.forEach((styleSrc) => {
|
||||||
|
ReactDOM.preinit(styleSrc, { as: 'style' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before interactive scripts need to be loaded by Next.js' runtime instead
|
||||||
|
// of native <script> tags, because they no longer have `defer`.
|
||||||
|
if (strategy === 'beforeInteractive') {
|
||||||
|
if (!src) {
|
||||||
|
// For inlined scripts, we put the content in `children`.
|
||||||
|
if (restProps.dangerouslySetInnerHTML) {
|
||||||
|
// Casting since lib.dom.d.ts doesn't have TrustedHTML yet.
|
||||||
|
restProps.children = restProps.dangerouslySetInnerHTML
|
||||||
|
.__html as string
|
||||||
|
delete restProps.dangerouslySetInnerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
nonce={nonce}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
|
||||||
|
0,
|
||||||
|
{ ...restProps, id },
|
||||||
|
])})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
ReactDOM.preload(
|
||||||
|
src,
|
||||||
|
restProps.integrity
|
||||||
|
? {
|
||||||
|
as: 'script',
|
||||||
|
integrity: restProps.integrity,
|
||||||
|
nonce,
|
||||||
|
crossOrigin: restProps.crossOrigin,
|
||||||
|
}
|
||||||
|
: { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
nonce={nonce}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
|
||||||
|
src,
|
||||||
|
{ ...restProps, id },
|
||||||
|
])})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (strategy === 'afterInteractive') {
|
||||||
|
if (src) {
|
||||||
|
// @ts-ignore
|
||||||
|
ReactDOM.preload(
|
||||||
|
src,
|
||||||
|
restProps.integrity
|
||||||
|
? {
|
||||||
|
as: 'script',
|
||||||
|
integrity: restProps.integrity,
|
||||||
|
nonce,
|
||||||
|
crossOrigin: restProps.crossOrigin,
|
||||||
|
}
|
||||||
|
: { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(Script, '__nextScript', { value: true })
|
||||||
|
|
||||||
|
export default Script
|
||||||
59
client/set-attributes-from-props.ts
Normal file
59
client/set-attributes-from-props.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const DOMAttributeNames: Record<string, string> = {
|
||||||
|
acceptCharset: 'accept-charset',
|
||||||
|
className: 'class',
|
||||||
|
htmlFor: 'for',
|
||||||
|
httpEquiv: 'http-equiv',
|
||||||
|
noModule: 'noModule',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoreProps = [
|
||||||
|
'onLoad',
|
||||||
|
'onReady',
|
||||||
|
'dangerouslySetInnerHTML',
|
||||||
|
'children',
|
||||||
|
'onError',
|
||||||
|
'strategy',
|
||||||
|
'stylesheets',
|
||||||
|
]
|
||||||
|
|
||||||
|
function isBooleanScriptAttribute(
|
||||||
|
attr: string
|
||||||
|
): attr is 'async' | 'defer' | 'noModule' {
|
||||||
|
return ['async', 'defer', 'noModule'].includes(attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAttributesFromProps(el: HTMLElement, props: object) {
|
||||||
|
for (const [p, value] of Object.entries(props)) {
|
||||||
|
if (!props.hasOwnProperty(p)) continue
|
||||||
|
if (ignoreProps.includes(p)) continue
|
||||||
|
|
||||||
|
// we don't render undefined props to the DOM
|
||||||
|
if (value === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const attr = DOMAttributeNames[p] || p.toLowerCase()
|
||||||
|
|
||||||
|
if (el.tagName === 'SCRIPT' && isBooleanScriptAttribute(attr)) {
|
||||||
|
// Correctly assign boolean script attributes
|
||||||
|
// https://github.com/vercel/next.js/pull/20748
|
||||||
|
;(el as HTMLScriptElement)[attr] = !!value
|
||||||
|
} else {
|
||||||
|
el.setAttribute(attr, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove falsy non-zero boolean attributes so they are correctly interpreted
|
||||||
|
// (e.g. if we set them to false, this coerces to the string "false", which the browser interprets as true)
|
||||||
|
if (
|
||||||
|
value === false ||
|
||||||
|
(el.tagName === 'SCRIPT' &&
|
||||||
|
isBooleanScriptAttribute(attr) &&
|
||||||
|
(!value || value === 'false'))
|
||||||
|
) {
|
||||||
|
// Call setAttribute before, as we need to set and unset the attribute to override force async:
|
||||||
|
// https://html.spec.whatwg.org/multipage/scripting.html#script-force-async
|
||||||
|
el.setAttribute(attr, '')
|
||||||
|
el.removeAttribute(attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
client/trusted-types.ts
Normal file
37
client/trusted-types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Stores the Trusted Types Policy. Starts as undefined and can be set to null
|
||||||
|
* if Trusted Types is not supported in the browser.
|
||||||
|
*/
|
||||||
|
let policy: TrustedTypePolicy | null | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for the Trusted Types Policy. If it is undefined, it is instantiated
|
||||||
|
* here or set to null if Trusted Types is not supported in the browser.
|
||||||
|
*/
|
||||||
|
function getPolicy() {
|
||||||
|
if (typeof policy === 'undefined' && typeof window !== 'undefined') {
|
||||||
|
policy =
|
||||||
|
window.trustedTypes?.createPolicy('nextjs', {
|
||||||
|
createHTML: (input) => input,
|
||||||
|
createScript: (input) => input,
|
||||||
|
createScriptURL: (input) => input,
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsafely promote a string to a TrustedScriptURL, falling back to strings
|
||||||
|
* when Trusted Types are not available.
|
||||||
|
* This is a security-sensitive function; any use of this function
|
||||||
|
* must go through security review. In particular, it must be assured that the
|
||||||
|
* provided string will never cause an XSS vulnerability if used in a context
|
||||||
|
* that will cause a browser to load and execute a resource, e.g. when
|
||||||
|
* assigning to script.src.
|
||||||
|
*/
|
||||||
|
export function __unsafeCreateTrustedScriptURL(
|
||||||
|
url: string
|
||||||
|
): TrustedScriptURL | string {
|
||||||
|
return getPolicy()?.createScriptURL(url) || url
|
||||||
|
}
|
||||||
39
client/webpack.ts
Normal file
39
client/webpack.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
declare const __webpack_require__: any
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
declare let __webpack_public_path__: string
|
||||||
|
|
||||||
|
import { getDeploymentIdQueryOrEmptyString } from '../build/deployment-id'
|
||||||
|
|
||||||
|
// If we have a deployment ID, we need to append it to the webpack chunk names
|
||||||
|
// I am keeping the process check explicit so this can be statically optimized
|
||||||
|
if (process.env.NEXT_DEPLOYMENT_ID) {
|
||||||
|
const suffix = getDeploymentIdQueryOrEmptyString()
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const getChunkScriptFilename = __webpack_require__.u
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
__webpack_require__.u = (...args: any[]) =>
|
||||||
|
// We enode the chunk filename because our static server matches against and encoded
|
||||||
|
// filename path.
|
||||||
|
getChunkScriptFilename(...args) + suffix
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const getChunkCssFilename = __webpack_require__.k
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
__webpack_require__.k = (...args: any[]) =>
|
||||||
|
getChunkCssFilename(...args) + suffix
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const getMiniCssFilename = __webpack_require__.miniCssF
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
__webpack_require__.miniCssF = (...args: any[]) =>
|
||||||
|
getMiniCssFilename(...args) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore the module ID transform in client.
|
||||||
|
;(self as any).__next_set_public_path__ = (path: string) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
__webpack_public_path__ = path
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
41
client/with-router.tsx
Normal file
41
client/with-router.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { type JSX } from 'react'
|
||||||
|
import type {
|
||||||
|
BaseContext,
|
||||||
|
NextComponentType,
|
||||||
|
NextPageContext,
|
||||||
|
} from '../shared/lib/utils'
|
||||||
|
import type { NextRouter } from './router'
|
||||||
|
import { useRouter } from './router'
|
||||||
|
|
||||||
|
export type WithRouterProps = {
|
||||||
|
router: NextRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExcludeRouterProps<P> = Pick<
|
||||||
|
P,
|
||||||
|
Exclude<keyof P, keyof WithRouterProps>
|
||||||
|
>
|
||||||
|
|
||||||
|
export default function withRouter<
|
||||||
|
P extends WithRouterProps,
|
||||||
|
C extends BaseContext = NextPageContext,
|
||||||
|
>(
|
||||||
|
ComposedComponent: NextComponentType<C, any, P>
|
||||||
|
): React.ComponentType<ExcludeRouterProps<P>> {
|
||||||
|
function WithRouterWrapper(props: any): JSX.Element {
|
||||||
|
return <ComposedComponent router={useRouter()} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
WithRouterWrapper.getInitialProps = ComposedComponent.getInitialProps
|
||||||
|
// This is needed to allow checking for custom getInitialProps in _app
|
||||||
|
;(WithRouterWrapper as any).origGetInitialProps = (
|
||||||
|
ComposedComponent as any
|
||||||
|
).origGetInitialProps
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const name =
|
||||||
|
ComposedComponent.displayName || ComposedComponent.name || 'Unknown'
|
||||||
|
WithRouterWrapper.displayName = `withRouter(${name})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return WithRouterWrapper
|
||||||
|
}
|
||||||
46
pages/_app.tsx
Normal file
46
pages/_app.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppContextType,
|
||||||
|
AppInitialProps,
|
||||||
|
AppPropsType,
|
||||||
|
NextWebVitalsMetric,
|
||||||
|
AppType,
|
||||||
|
} from '../shared/lib/utils'
|
||||||
|
import type { Router } from '../client/router'
|
||||||
|
|
||||||
|
import { loadGetInitialProps } from '../shared/lib/utils'
|
||||||
|
|
||||||
|
export type { AppInitialProps, AppType }
|
||||||
|
|
||||||
|
export type { NextWebVitalsMetric }
|
||||||
|
|
||||||
|
export type AppContext = AppContextType<Router>
|
||||||
|
|
||||||
|
export type AppProps<P = any> = AppPropsType<Router, P>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `App` component is used for initialize of pages. It allows for overwriting and full control of the `page` initialization.
|
||||||
|
* This allows for keeping state between navigation, custom error handling, injecting additional data.
|
||||||
|
*/
|
||||||
|
async function appGetInitialProps({
|
||||||
|
Component,
|
||||||
|
ctx,
|
||||||
|
}: AppContext): Promise<AppInitialProps> {
|
||||||
|
const pageProps = await loadGetInitialProps(Component, ctx)
|
||||||
|
return { pageProps }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class App<P = any, CP = {}, S = {}> extends React.Component<
|
||||||
|
P & AppProps<CP>,
|
||||||
|
S
|
||||||
|
> {
|
||||||
|
static origGetInitialProps = appGetInitialProps
|
||||||
|
static getInitialProps = appGetInitialProps
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { Component, pageProps } = this.props as AppProps<CP>
|
||||||
|
|
||||||
|
return <Component {...pageProps} />
|
||||||
|
}
|
||||||
|
}
|
||||||
156
pages/_error.tsx
Normal file
156
pages/_error.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Head from '../shared/lib/head'
|
||||||
|
import type { NextPageContext } from '../shared/lib/utils'
|
||||||
|
|
||||||
|
const statusCodes: { [code: number]: string } = {
|
||||||
|
400: 'Bad Request',
|
||||||
|
404: 'This page could not be found',
|
||||||
|
405: 'Method Not Allowed',
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorProps = {
|
||||||
|
statusCode: number
|
||||||
|
hostname?: string
|
||||||
|
title?: string
|
||||||
|
withDarkMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getInitialProps({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
err,
|
||||||
|
}: NextPageContext): Promise<ErrorProps> | ErrorProps {
|
||||||
|
const statusCode =
|
||||||
|
res && res.statusCode ? res.statusCode : err ? err.statusCode! : 404
|
||||||
|
|
||||||
|
let hostname
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
hostname = window.location.hostname
|
||||||
|
} else if (req) {
|
||||||
|
const { getRequestMeta } =
|
||||||
|
require('../server/request-meta') as typeof import('../server/request-meta')
|
||||||
|
|
||||||
|
const initUrl = getRequestMeta(req, 'initURL')
|
||||||
|
if (initUrl) {
|
||||||
|
const url = new URL(initUrl)
|
||||||
|
hostname = url.hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statusCode, hostname }
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
error: {
|
||||||
|
// https://github.com/sindresorhus/modern-normalize/blob/main/modern-normalize.css#L38-L52
|
||||||
|
fontFamily:
|
||||||
|
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
|
||||||
|
height: '100vh',
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
desc: {
|
||||||
|
lineHeight: '48px',
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '0 20px 0 0',
|
||||||
|
paddingRight: 23,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
verticalAlign: 'top',
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '28px',
|
||||||
|
},
|
||||||
|
wrap: {
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Error` component used for handling errors.
|
||||||
|
*/
|
||||||
|
export default class Error<P = {}> extends React.Component<P & ErrorProps> {
|
||||||
|
static displayName = 'ErrorPage'
|
||||||
|
|
||||||
|
static getInitialProps = _getInitialProps
|
||||||
|
static origGetInitialProps = _getInitialProps
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { statusCode, withDarkMode = true } = this.props
|
||||||
|
const title =
|
||||||
|
this.props.title ||
|
||||||
|
statusCodes[statusCode] ||
|
||||||
|
'An unexpected error has occurred'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.error}>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{statusCode
|
||||||
|
? `${statusCode}: ${title}`
|
||||||
|
: 'Application error: a client-side exception has occurred'}
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
|
<div style={styles.desc}>
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
/* CSS minified from
|
||||||
|
body { margin: 0; color: #000; background: #fff; }
|
||||||
|
.next-error-h1 {
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
${
|
||||||
|
withDarkMode
|
||||||
|
? `@media (prefers-color-scheme: dark) {
|
||||||
|
body { color: #fff; background: #000; }
|
||||||
|
.next-error-h1 {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, .3);
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
__html: `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}${
|
||||||
|
withDarkMode
|
||||||
|
? '@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}'
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{statusCode ? (
|
||||||
|
<h1 className="next-error-h1" style={styles.h1}>
|
||||||
|
{statusCode}
|
||||||
|
</h1>
|
||||||
|
) : null}
|
||||||
|
<div style={styles.wrap}>
|
||||||
|
<h2 style={styles.h2}>
|
||||||
|
{this.props.title || statusCode ? (
|
||||||
|
title
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Application error: a client-side exception has occurred{' '}
|
||||||
|
{Boolean(this.props.hostname) && (
|
||||||
|
<>while loading {this.props.hostname}</>
|
||||||
|
)}{' '}
|
||||||
|
(see the browser console for more information)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
sourcemaps/2000-f33978e1dee8a081.js.map
Normal file
1
sourcemaps/2000-f33978e1dee8a081.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/2314-fe458c2a95ca9eff.js.map
Normal file
1
sourcemaps/2314-fe458c2a95ca9eff.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/2755-edd9c9279b46e51c.js.map
Normal file
1
sourcemaps/2755-edd9c9279b46e51c.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/3556-8a77b49726b7e707.js.map
Normal file
1
sourcemaps/3556-8a77b49726b7e707.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/3951-c864eaa98ec814ae.js.map
Normal file
1
sourcemaps/3951-c864eaa98ec814ae.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/4664-7d8b6e1084fdbfa9.js.map
Normal file
1
sourcemaps/4664-7d8b6e1084fdbfa9.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/4955-ebe216249d947546.js.map
Normal file
1
sourcemaps/4955-ebe216249d947546.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/6870-9af9863b9bfe3bff.js.map
Normal file
1
sourcemaps/6870-9af9863b9bfe3bff.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/774-630171de21431acc.js.map
Normal file
1
sourcemaps/774-630171de21431acc.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/7902-cc8af8cfe364ab73.js.map
Normal file
1
sourcemaps/7902-cc8af8cfe364ab73.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/8662-4e9f49f650667938.js.map
Normal file
1
sourcemaps/8662-4e9f49f650667938.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/8704-3992a47e8d7100d0.js.map
Normal file
1
sourcemaps/8704-3992a47e8d7100d0.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/8994-7372261399f22232.js.map
Normal file
1
sourcemaps/8994-7372261399f22232.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/9306-7a5de9a4cb1e409e.js.map
Normal file
1
sourcemaps/9306-7a5de9a4cb1e409e.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/_app-583e09fffc54549d.js.map
Normal file
1
sourcemaps/_app-583e09fffc54549d.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/analytics-1f4905dbd0f7c7cc.js.map
Normal file
1
sourcemaps/analytics-1f4905dbd0f7c7cc.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/campaigns-97d5f24df9bb9d33.js.map
Normal file
1
sourcemaps/campaigns-97d5f24df9bb9d33.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/creatives-770116cb34bfd5d1.js.map
Normal file
1
sourcemaps/creatives-770116cb34bfd5d1.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/custom-reports-new-651c0a1150d2ebc1.js.map
Normal file
1
sourcemaps/custom-reports-new-651c0a1150d2ebc1.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/demand-f7a7d01fb4629a28.js.map
Normal file
1
sourcemaps/demand-f7a7d01fb4629a28.js.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/chunks/pages/demand-f7a7d01fb4629a28.js","mappings":"+EACA,4CACA,UACA,WACA,OAAe,EAAQ,KAAqC,CAC5D,EACA,SAFsB,uGCCP,SAASA,IACtB,MACE,UAACC,EAAAA,CAAMA,CAAAA,UACL,UAACC,EAAAA,CAAQA,CAAAA,CAAAA,IAGf","sources":["webpack://_N_E/?30a6","webpack://_N_E/./src/pages/demand/index.jsx"],"sourcesContent":["\n (window.__NEXT_P = window.__NEXT_P || []).push([\n \"/demand\",\n function () {\n return require(\"private-next-pages/demand/index.jsx\");\n }\n ]);\n if(module.hot) {\n module.hot.dispose(function () {\n window.__NEXT_P.push([\"/demand\"])\n });\n }\n ","import React from 'react';\n\nimport Layout from '@/modules/layout';\nimport Accounts from '@/modules/marketplace/accounts';\n\nexport default function Home() {\n return (\n <Layout>\n <Accounts />\n </Layout>\n );\n}\n"],"names":["Home","Layout","Accounts"],"sourceRoot":"","ignoreList":[]}
|
||||||
1
sourcemaps/forms-656364523b80344b.js.map
Normal file
1
sourcemaps/forms-656364523b80344b.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/framework-b93321030af422c2.js.map
Normal file
1
sourcemaps/framework-b93321030af422c2.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/hubs-6dd4b2654e93598a.js.map
Normal file
1
sourcemaps/hubs-6dd4b2654e93598a.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/invitations-e6c704a0de27fb9c.js.map
Normal file
1
sourcemaps/invitations-e6c704a0de27fb9c.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/line-items-0ee9449a27dce35f.js.map
Normal file
1
sourcemaps/line-items-0ee9449a27dce35f.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/main-439d8d2377b3f45b.js.map
Normal file
1
sourcemaps/main-439d8d2377b3f45b.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/monetization-8480373132e24aed.js.map
Normal file
1
sourcemaps/monetization-8480373132e24aed.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/personal-settings-0c29c07b7a04335a.js.map
Normal file
1
sourcemaps/personal-settings-0c29c07b7a04335a.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/studio-a98ce8bc5d5d030a.js.map
Normal file
1
sourcemaps/studio-a98ce8bc5d5d030a.js.map
Normal file
File diff suppressed because one or more lines are too long
1
sourcemaps/users-bc5a585c17a8e2ab.js.map
Normal file
1
sourcemaps/users-bc5a585c17a8e2ab.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
sourcemaps/webpack-99afbd3b5b2ffbb2.js.map
Normal file
1
sourcemaps/webpack-99afbd3b5b2ffbb2.js.map
Normal file
File diff suppressed because one or more lines are too long
1
src/assets/img/empty.svg
Normal file
1
src/assets/img/empty.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/empty.cdfa4b17.svg","height":173,"width":173,"blurWidth":0,"blurHeight":0};
|
||||||
1
src/assets/img/logo-symbol.png
Normal file
1
src/assets/img/logo-symbol.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/logo-symbol.650de8de.png","height":128,"width":129,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAMAAADz0U65AAAAJ1BMVEVMaXH////////////////////////////////////////////////c+C/6AAAADHRSTlMADEsbbzRgJJ/W67YnuZu8AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAL0lEQVR4nGNgQABGZg5uZhDNxMLGxcnIwMDMwsrEysMEZrCBGXApBkZ2Dm52hGYAGlIAxcwQGZ8AAAAASUVORK5CYII=","blurWidth":8,"blurHeight":8};
|
||||||
1
src/assets/img/logo-text.png
Normal file
1
src/assets/img/logo-text.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/logo-text.6e2d1f50.png","height":58,"width":357,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAABCAMAAADU3h9xAAAADFBMVEX////////////////1pQ5zAAAABHRSTlNbTEBqiwFd3gAAAAlwSFlzAAALEwAACxMBAJqcGAAAABFJREFUeJxjYGZgZGBkYmAEAAAyAAlbjYepAAAAAElFTkSuQmCC","blurWidth":8,"blurHeight":1};
|
||||||
1
src/assets/img/no-image-portrait.svg
Normal file
1
src/assets/img/no-image-portrait.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/no-image-portrait.aa1451b2.svg","height":600,"width":406,"blurWidth":0,"blurHeight":0};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export const HTTPAccessErrorStatus = {
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus))
|
||||||
|
|
||||||
|
export const HTTP_ERROR_FALLBACK_ERROR_CODE = 'NEXT_HTTP_ERROR_FALLBACK'
|
||||||
|
|
||||||
|
export type HTTPAccessFallbackError = Error & {
|
||||||
|
digest: `${typeof HTTP_ERROR_FALLBACK_ERROR_CODE};${string}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks an error to determine if it's an error generated by
|
||||||
|
* the HTTP navigation APIs `notFound()`, `forbidden()` or `unauthorized()`.
|
||||||
|
*
|
||||||
|
* @param error the error that may reference a HTTP access error
|
||||||
|
* @returns true if the error is a HTTP access error
|
||||||
|
*/
|
||||||
|
export function isHTTPAccessFallbackError(
|
||||||
|
error: unknown
|
||||||
|
): error is HTTPAccessFallbackError {
|
||||||
|
if (
|
||||||
|
typeof error !== 'object' ||
|
||||||
|
error === null ||
|
||||||
|
!('digest' in error) ||
|
||||||
|
typeof error.digest !== 'string'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const [prefix, httpStatus] = error.digest.split(';')
|
||||||
|
|
||||||
|
return (
|
||||||
|
prefix === HTTP_ERROR_FALLBACK_ERROR_CODE &&
|
||||||
|
ALLOWED_CODES.has(Number(httpStatus))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessFallbackHTTPStatus(
|
||||||
|
error: HTTPAccessFallbackError
|
||||||
|
): number {
|
||||||
|
const httpStatus = error.digest.split(';')[1]
|
||||||
|
return Number(httpStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessFallbackErrorTypeByStatus(
|
||||||
|
status: number
|
||||||
|
): 'not-found' | 'forbidden' | 'unauthorized' | undefined {
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
return 'unauthorized'
|
||||||
|
case 403:
|
||||||
|
return 'forbidden'
|
||||||
|
case 404:
|
||||||
|
return 'not-found'
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/client/components/is-next-router-error.ts
Normal file
16
src/client/components/is-next-router-error.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
isHTTPAccessFallbackError,
|
||||||
|
type HTTPAccessFallbackError,
|
||||||
|
} from './http-access-fallback/http-access-fallback'
|
||||||
|
import { isRedirectError, type RedirectError } from './redirect-error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the error is a navigation signal error. These errors are
|
||||||
|
* thrown by user code to perform navigation operations and interrupt the React
|
||||||
|
* render.
|
||||||
|
*/
|
||||||
|
export function isNextRouterError(
|
||||||
|
error: unknown
|
||||||
|
): error is RedirectError | HTTPAccessFallbackError {
|
||||||
|
return isRedirectError(error) || isHTTPAccessFallbackError(error)
|
||||||
|
}
|
||||||
45
src/client/components/redirect-error.ts
Normal file
45
src/client/components/redirect-error.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { RedirectStatusCode } from './redirect-status-code'
|
||||||
|
|
||||||
|
export const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'
|
||||||
|
|
||||||
|
export enum RedirectType {
|
||||||
|
push = 'push',
|
||||||
|
replace = 'replace',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedirectError = Error & {
|
||||||
|
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${string};${RedirectStatusCode};`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks an error to determine if it's an error generated by the
|
||||||
|
* `redirect(url)` helper.
|
||||||
|
*
|
||||||
|
* @param error the error that may reference a redirect error
|
||||||
|
* @returns true if the error is a redirect error
|
||||||
|
*/
|
||||||
|
export function isRedirectError(error: unknown): error is RedirectError {
|
||||||
|
if (
|
||||||
|
typeof error !== 'object' ||
|
||||||
|
error === null ||
|
||||||
|
!('digest' in error) ||
|
||||||
|
typeof error.digest !== 'string'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = error.digest.split(';')
|
||||||
|
const [errorCode, type] = digest
|
||||||
|
const destination = digest.slice(2, -2).join(';')
|
||||||
|
const status = digest.at(-2)
|
||||||
|
|
||||||
|
const statusCode = Number(status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
errorCode === REDIRECT_ERROR_CODE &&
|
||||||
|
(type === 'replace' || type === 'push') &&
|
||||||
|
typeof destination === 'string' &&
|
||||||
|
!isNaN(statusCode) &&
|
||||||
|
statusCode in RedirectStatusCode
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/client/components/redirect-status-code.ts
Normal file
5
src/client/components/redirect-status-code.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum RedirectStatusCode {
|
||||||
|
SeeOther = 303,
|
||||||
|
TemporaryRedirect = 307,
|
||||||
|
PermanentRedirect = 308,
|
||||||
|
}
|
||||||
22
src/client/portal/index.tsx
Normal file
22
src/client/portal/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
type PortalProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Portal = ({ children, type }: PortalProps) => {
|
||||||
|
const [portalNode, setPortalNode] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = document.createElement(type)
|
||||||
|
document.body.appendChild(element)
|
||||||
|
setPortalNode(element)
|
||||||
|
return () => {
|
||||||
|
document.body.removeChild(element)
|
||||||
|
}
|
||||||
|
}, [type])
|
||||||
|
|
||||||
|
return portalNode ? createPortal(children, portalNode) : null
|
||||||
|
}
|
||||||
31
src/client/react-client-callbacks/on-recoverable-error.ts
Normal file
31
src/client/react-client-callbacks/on-recoverable-error.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// This module can be shared between both pages router and app router
|
||||||
|
|
||||||
|
import type { HydrationOptions } from 'react-dom/client'
|
||||||
|
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
|
||||||
|
import isError from '../../lib/is-error'
|
||||||
|
import { reportGlobalError } from './report-global-error'
|
||||||
|
|
||||||
|
const recoverableErrors = new WeakSet<Error>()
|
||||||
|
|
||||||
|
export function isRecoverableError(error: Error): boolean {
|
||||||
|
return recoverableErrors.has(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRecoverableError: HydrationOptions['onRecoverableError'] = (
|
||||||
|
error
|
||||||
|
) => {
|
||||||
|
// x-ref: https://github.com/facebook/react/pull/28736
|
||||||
|
let cause = isError(error) && 'cause' in error ? error.cause : error
|
||||||
|
// Skip certain custom errors which are not expected to be reported on client
|
||||||
|
if (isBailoutToCSRError(cause)) return
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const { decorateDevError } =
|
||||||
|
require('../../next-devtools/userspace/app/errors/stitched-error') as typeof import('../../next-devtools/userspace/app/errors/stitched-error')
|
||||||
|
const causeError = decorateDevError(cause)
|
||||||
|
recoverableErrors.add(causeError)
|
||||||
|
cause = causeError
|
||||||
|
}
|
||||||
|
|
||||||
|
reportGlobalError(cause)
|
||||||
|
}
|
||||||
9
src/client/react-client-callbacks/report-global-error.ts
Normal file
9
src/client/react-client-callbacks/report-global-error.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const reportGlobalError =
|
||||||
|
typeof reportError === 'function'
|
||||||
|
? // In modern browsers, reportError will dispatch an error event,
|
||||||
|
// emulating an uncaught JavaScript error.
|
||||||
|
reportError
|
||||||
|
: (error: unknown) => {
|
||||||
|
// TODO: Dispatch error event
|
||||||
|
globalThis.console.error(error)
|
||||||
|
}
|
||||||
79
src/client/tracing/tracer.ts
Normal file
79
src/client/tracing/tracer.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import mitt from '../../shared/lib/mitt'
|
||||||
|
import type { MittEmitter } from '../../shared/lib/mitt'
|
||||||
|
|
||||||
|
export type SpanOptions = {
|
||||||
|
startTime?: number
|
||||||
|
attributes?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpanState =
|
||||||
|
| {
|
||||||
|
state: 'inprogress'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: 'ended'
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISpan {
|
||||||
|
name: string
|
||||||
|
startTime: number
|
||||||
|
attributes: Record<string, unknown>
|
||||||
|
state: SpanState
|
||||||
|
end(endTime?: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class Span implements ISpan {
|
||||||
|
name: string
|
||||||
|
startTime: number
|
||||||
|
onSpanEnd: (span: Span) => void
|
||||||
|
state: SpanState
|
||||||
|
attributes: Record<string, unknown>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
options: SpanOptions,
|
||||||
|
onSpanEnd: (span: Span) => void
|
||||||
|
) {
|
||||||
|
this.name = name
|
||||||
|
this.attributes = options.attributes ?? {}
|
||||||
|
this.startTime = options.startTime ?? Date.now()
|
||||||
|
this.onSpanEnd = onSpanEnd
|
||||||
|
this.state = { state: 'inprogress' }
|
||||||
|
}
|
||||||
|
|
||||||
|
end(endTime?: number) {
|
||||||
|
if (this.state.state === 'ended') {
|
||||||
|
throw new Error('Span has already ended')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
state: 'ended',
|
||||||
|
endTime: endTime ?? Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onSpanEnd(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tracer {
|
||||||
|
_emitter: MittEmitter<string> = mitt()
|
||||||
|
|
||||||
|
private handleSpanEnd = (span: Span) => {
|
||||||
|
this._emitter.emit('spanend', span)
|
||||||
|
}
|
||||||
|
|
||||||
|
startSpan(name: string, options: SpanOptions) {
|
||||||
|
return new Span(name, options, this.handleSpanEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSpanEnd(cb: (span: ISpan) => void): () => void {
|
||||||
|
this._emitter.on('spanend', cb)
|
||||||
|
return () => {
|
||||||
|
this._emitter.off('spanend', cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ISpan as Span }
|
||||||
|
export default new Tracer()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezonePlugin from 'dayjs/plugin/timezone';
|
||||||
|
import utcPlugin from 'dayjs/plugin/utc';
|
||||||
|
|
||||||
|
import { abbreviateNumber } from '@/modules/@common/helpers/number';
|
||||||
|
import { getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors';
|
||||||
|
|
||||||
|
import styles from './CustomTooltip.module.scss';
|
||||||
|
|
||||||
|
dayjs.extend(utcPlugin);
|
||||||
|
dayjs.extend(timezonePlugin);
|
||||||
|
|
||||||
|
function CustomTooltip({ tooltipDateFormatter = null, ...props }) {
|
||||||
|
const timezone = useSelector(getUserTimezoneSelector);
|
||||||
|
return props.active && props.payload?.length ? (
|
||||||
|
<div className={styles.Tooltip} style={{ backgroundColor: props.backgroundColor }}>
|
||||||
|
{props.payload.map((item, index) => {
|
||||||
|
if (item.color !== 'transparent') {
|
||||||
|
return (
|
||||||
|
<div key={`${index + 1}-${item.value}`}>
|
||||||
|
{tooltipDateFormatter && item?.payload?.time && (
|
||||||
|
<div>{tooltipDateFormatter(dayjs(item.payload.time).tz(timezone))}</div>
|
||||||
|
)}
|
||||||
|
{abbreviateNumber(item.value)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomTooltip.propTypes = {
|
||||||
|
active: PropTypes.bool.isRequired,
|
||||||
|
payload: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||||
|
backgroundColor: PropTypes.string.isRequired,
|
||||||
|
tooltipDateFormatter: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomTooltip;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Tooltip":"CustomTooltip_Tooltip__TDEyZ"};
|
||||||
62
src/modules/analytics/common/components/AreaGraph/index.jsx
Normal file
62
src/modules/analytics/common/components/AreaGraph/index.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import CustomTooltip from './CustomTooltip';
|
||||||
|
|
||||||
|
function AreaGraph({ gradient = 'areaGradient', tooltipDateFormatter = null, ...props }) {
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer height="99.99%" width="99.99%">
|
||||||
|
<AreaChart
|
||||||
|
data={props.data}
|
||||||
|
margin={{
|
||||||
|
top: 2,
|
||||||
|
bottom: 0,
|
||||||
|
right: 2,
|
||||||
|
left: 3,
|
||||||
|
}}
|
||||||
|
// margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGradient" gradientTransform="rotate(0)">
|
||||||
|
<stop offset="-12%" stopColor={theme.palette.primary.main} />
|
||||||
|
<stop offset="93%" stopColor="rgba(217, 217, 217, 0)" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="areaGradient2" gradientTransform="rotate(0.12)">
|
||||||
|
<stop offset="-57.98%" stopColor={theme.palette.secondary.light} />
|
||||||
|
<stop offset="99.89%" stopColor="rgba(217, 217, 217, 0)" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="areaGradient3" gradientTransform="rotate(0.12)">
|
||||||
|
<stop offset="-57.98%" stopColor={theme.palette.success.light} />
|
||||||
|
<stop offset="99.89%" stopColor="rgba(217, 217, 217, 0)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip backgroundColor={props.stroke} tooltipDateFormatter={tooltipDateFormatter} />}
|
||||||
|
cursor={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Area type="monotone" dataKey="value" stroke={props.stroke} fillOpacity={1} fill={`url(#${gradient})`} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AreaGraph.propTypes = {
|
||||||
|
data: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
time: PropTypes.number.isRequired,
|
||||||
|
value: PropTypes.number.isRequired,
|
||||||
|
}),
|
||||||
|
).isRequired,
|
||||||
|
stroke: PropTypes.string.isRequired,
|
||||||
|
gradient: PropTypes.string,
|
||||||
|
tooltipDateFormatter: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AreaGraph;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"FormControl":"DialogCalendarRange_FormControl__RVYa_","Calendar":"DialogCalendarRange_Calendar__Y_0_O"};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
StaticDateRangePicker,
|
||||||
|
TextField,
|
||||||
|
} from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './DialogCalendarRange.module.scss';
|
||||||
|
|
||||||
|
const defineInterval = (diff) => {
|
||||||
|
if (diff > 14) {
|
||||||
|
return {
|
||||||
|
interval: 'WEEKS',
|
||||||
|
videoChartDataKey: 'date',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (diff > 1) {
|
||||||
|
return {
|
||||||
|
interval: 'DAYS',
|
||||||
|
videoChartDataKey: 'date',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
interval: 'HOURS',
|
||||||
|
videoChartDataKey: 'hours',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogCalendarRange(props) {
|
||||||
|
const [dateStart, setDateStart] = useState(dayjs(new Date()).add(-30, 'days'));
|
||||||
|
const [dateEnd, setDateEnd] = useState(dayjs(new Date()));
|
||||||
|
|
||||||
|
const handleDateSubmit = () => {
|
||||||
|
// do not change the format 'YYYY-MM-DD' to another or it will corrupt backend
|
||||||
|
const toFormat = (date) => (date ? dayjs(date).format('YYYY-MM-DD') : '');
|
||||||
|
const diff = dayjs(dateEnd).diff(dateStart, 'days');
|
||||||
|
const changesStringFrom = dayjs(dateStart).subtract(diff, 'day');
|
||||||
|
|
||||||
|
const { interval, videoChartDataKey } = defineInterval(Math.abs(diff));
|
||||||
|
|
||||||
|
props.onDateSubmit({
|
||||||
|
dateStart: toFormat(dateStart),
|
||||||
|
dateEnd: toFormat(dateEnd),
|
||||||
|
comparison: {
|
||||||
|
dateStart: toFormat(changesStringFrom),
|
||||||
|
dateEnd: toFormat(dateStart),
|
||||||
|
},
|
||||||
|
interval,
|
||||||
|
videoChartDataKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onClose={props.onClose}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
maxWidth="xl"
|
||||||
|
>
|
||||||
|
<DialogTitle>Custom range</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack direction="row" justifyContent="center" spacing={13}>
|
||||||
|
<TextField
|
||||||
|
className={styles.FormControl}
|
||||||
|
data-id="from"
|
||||||
|
value={dayjs(dateStart).format('MMM DD, YYYY')}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
label="From"
|
||||||
|
textAlign="center"
|
||||||
|
inputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.FormControl}
|
||||||
|
data-id="from"
|
||||||
|
value={dayjs(dateEnd).format('MMM DD, YYYY')}
|
||||||
|
variant="outlined"
|
||||||
|
textAlign="center"
|
||||||
|
size="small"
|
||||||
|
label="To"
|
||||||
|
inputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<div className={styles.Calendar}>
|
||||||
|
<StaticDateRangePicker
|
||||||
|
value={[dayjs(dateStart), dayjs(dateEnd)]}
|
||||||
|
calendars={2}
|
||||||
|
slotProps={{
|
||||||
|
toolbar: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
actionBar: {
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
minDate={dayjs(new Date()).subtract(props.minDate || 90, 'day')}
|
||||||
|
maxDate={dayjs(new Date())}
|
||||||
|
onChange={([dateStart$, dateEnd$]) => {
|
||||||
|
setDateStart(dateStart$);
|
||||||
|
setDateEnd(dateEnd$);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button size="small" variant="contained" color="primary" onClick={handleDateSubmit}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogCalendarRange.propTypes = {
|
||||||
|
open: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onDateSubmit: PropTypes.func.isRequired,
|
||||||
|
minDate: PropTypes.number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DialogCalendarRange;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"GlobalStateEmpty_Wrapper__S8zu7","ImageWrapper":"GlobalStateEmpty_ImageWrapper__e5pMt","Image":"GlobalStateEmpty_Image__RfO6c","Description":"GlobalStateEmpty_Description__zYOSa","CtaWrapper":"GlobalStateEmpty_CtaWrapper__t0nTZ","Button":"GlobalStateEmpty_Button__ltHZe"};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/img.c6068819.png","height":496,"width":794,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAFCAMAAABPT11nAAAARVBMVEVU1OkseI41i55Ju9BP0OYXSmhDoK5MaXFNxdlR0uRm//9Pvc4A//9Z4fY5mrJ///8bUnEmZXxa5/86l6c9or1GuNFGt87Jv1WKAAAAFHRSTlP9/fv2UZb8AKNgBZQCrZYC/JKYlO9xIIkAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAxSURBVHicBcEHAsAgCACxU0Ggy9Hq/5/aBLvEPQ7DTgBvSK2UvIOZ05f6upF3qOoTPxniAUkMOkARAAAAAElFTkSuQmCC","blurWidth":8,"blurHeight":5};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
import { EDITORIAL_PAGE } from '@/modules/@common/router/constants';
|
||||||
|
|
||||||
|
import RoundItemContainer from '../RoundItemContainer';
|
||||||
|
import { Button, Grid, Typography } from '@/mui/components';
|
||||||
|
|
||||||
|
import img from './img/img.png';
|
||||||
|
|
||||||
|
import styles from './GlobalStateEmpty.module.scss';
|
||||||
|
|
||||||
|
function StateEmpty() {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<RoundItemContainer
|
||||||
|
container
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
direction="column"
|
||||||
|
className={styles.Wrapper}
|
||||||
|
>
|
||||||
|
<Grid item className={styles.ImageWrapper}>
|
||||||
|
<Image src={img} alt="test" className={styles.Image} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item className={styles.Description}>
|
||||||
|
<Typography variant="h6" color="text.secondary" fontWeight={400} component="div">
|
||||||
|
Use cutting-edge analytics to identify new trends and measure indicators to make decisions confidently. <br />{' '}
|
||||||
|
Start uploading, viewing, sharing, commenting and searching videos!
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item alignItems="center" className={styles.CtaWrapper}>
|
||||||
|
<Button
|
||||||
|
className={styles.Button}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => router.push(EDITORIAL_PAGE.path)}
|
||||||
|
>
|
||||||
|
<span>Let's Get Started</span>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</RoundItemContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StateEmpty;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"GlobalStateEmpty_Wrapper__8V0Cd"};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { WIDGET_DISPLAY_STATE_ERROR } from '@/modules/analytics/common/constants';
|
||||||
|
|
||||||
|
import RoundItemContainer from '../RoundItemContainer';
|
||||||
|
|
||||||
|
import styles from './GlobalStateEmpty.module.scss';
|
||||||
|
|
||||||
|
function GlobalStateError() {
|
||||||
|
return <RoundItemContainer displayState={WIDGET_DISPLAY_STATE_ERROR} container className={styles.Wrapper} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalStateError;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Header":"Header_Header__2RXVu"};
|
||||||
58
src/modules/analytics/common/components/Header/index.jsx
Normal file
58
src/modules/analytics/common/components/Header/index.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { PictureAsPdfRounded } from '@mui/icons-material';
|
||||||
|
|
||||||
|
import { PDF_EXPORT_HIDE_CONTENT } from '@/modules/analytics/common/constants';
|
||||||
|
|
||||||
|
import { CircularProgress, Grid, IconButton, Typography } from '@/mui/components';
|
||||||
|
import { CustomCsvFilled } from '@/mui/components/CustomIcon';
|
||||||
|
|
||||||
|
import styles from './Header.module.scss';
|
||||||
|
|
||||||
|
function Header(props) {
|
||||||
|
return (
|
||||||
|
<Grid className={styles.Header} container justifyContent="space-between" alignItems="center">
|
||||||
|
<Grid item>
|
||||||
|
<Typography variant="h5" color="text.primary" fontWeight={400}>
|
||||||
|
{props.children}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<div className={styles.Buttons} data-pdf-export={PDF_EXPORT_HIDE_CONTENT}>
|
||||||
|
<IconButton
|
||||||
|
disabled={props.disabledExport || props.isExportPdfLoading}
|
||||||
|
onClick={props.onExportToPdf}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{props.isExportPdfLoading ? (
|
||||||
|
<CircularProgress color="info" size={14} />
|
||||||
|
) : (
|
||||||
|
<PictureAsPdfRounded fontSize="large" htmlColor="info" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
disabled={props.disabledExport || props.isExportCsvLoading}
|
||||||
|
onClick={props.onExportToCsv}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{props.isExportCsvLoading ? (
|
||||||
|
<CircularProgress color="info" size={14} />
|
||||||
|
) : (
|
||||||
|
<CustomCsvFilled fontSize="large" color="info" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Header.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
onExportToPdf: PropTypes.func.isRequired,
|
||||||
|
onExportToCsv: PropTypes.func.isRequired,
|
||||||
|
isExportCsvLoading: PropTypes.bool.isRequired,
|
||||||
|
isExportPdfLoading: PropTypes.bool.isRequired,
|
||||||
|
disabledExport: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Layout":"Layout_Layout__gtqQi","Content":"Layout_Content__eroPg"};
|
||||||
22
src/modules/analytics/common/components/Layout/index.jsx
Normal file
22
src/modules/analytics/common/components/Layout/index.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'clsx';
|
||||||
|
|
||||||
|
import { Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './Layout.module.scss';
|
||||||
|
|
||||||
|
const Layout = forwardRef(({ contentClassName = null, ...props }, ref) => (
|
||||||
|
<Grid container className={styles.Layout}>
|
||||||
|
<Grid className={classNames(styles.Content, contentClassName)} ref={ref}>
|
||||||
|
{props.children}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
));
|
||||||
|
|
||||||
|
Layout.propTypes = {
|
||||||
|
contentClassName: PropTypes.string,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"RoundItemContainer_Wrapper__3xXcA","ResetPadding":"RoundItemContainer_ResetPadding__ZtEhe"};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'clsx';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WIDGET_DISPLAY_STATE_DATA,
|
||||||
|
WIDGET_DISPLAY_STATE_ERROR,
|
||||||
|
WIDGET_DISPLAY_STATE_NO_DATA,
|
||||||
|
} from '@/modules/analytics/common/constants';
|
||||||
|
|
||||||
|
import Stub from '@/modules/analytics/common/components/Stub';
|
||||||
|
import { Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './RoundItemContainer.module.scss';
|
||||||
|
|
||||||
|
function RoundItemContainer({
|
||||||
|
className = '',
|
||||||
|
displayState = WIDGET_DISPLAY_STATE_DATA,
|
||||||
|
type = 'vertical',
|
||||||
|
size = 'medium',
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const getDisplayItem = {
|
||||||
|
[WIDGET_DISPLAY_STATE_DATA]: props.children,
|
||||||
|
[WIDGET_DISPLAY_STATE_NO_DATA]: (
|
||||||
|
<Stub
|
||||||
|
imageType={WIDGET_DISPLAY_STATE_NO_DATA}
|
||||||
|
type={type}
|
||||||
|
size={size}
|
||||||
|
firstTitle="No Data..."
|
||||||
|
firstSubTitle={
|
||||||
|
size === 'large'
|
||||||
|
? 'No Data matching selected filters. Try adjusting your filters.'
|
||||||
|
: 'No Data matching selected filters.'
|
||||||
|
}
|
||||||
|
secondSubTitle={size === 'large' ? null : 'Try adjusting your filters.'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[WIDGET_DISPLAY_STATE_ERROR]: (
|
||||||
|
<Stub
|
||||||
|
imageType={WIDGET_DISPLAY_STATE_ERROR}
|
||||||
|
type={type}
|
||||||
|
size={size}
|
||||||
|
firstTitle={size === 'medium' || size === 'large' ? 'Error has occured...' : 'Error'}
|
||||||
|
secondTitle={size === 'medium' || size === 'large' ? null : 'has occured...'}
|
||||||
|
firstSubTitle={size === 'large' ? 'Something went wrong. Try adjusting your filters.' : 'Something went wrong.'}
|
||||||
|
secondSubTitle={size === 'large' ? null : 'Try adjusting your filters.'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldResetPadding = [WIDGET_DISPLAY_STATE_NO_DATA, WIDGET_DISPLAY_STATE_ERROR].includes(displayState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
{...props}
|
||||||
|
className={classNames(styles.Wrapper, className, {
|
||||||
|
[styles.ResetPadding]: shouldResetPadding,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{getDisplayItem[displayState]}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RoundItemContainer.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
className: PropTypes.string,
|
||||||
|
displayState: PropTypes.oneOf([WIDGET_DISPLAY_STATE_DATA, WIDGET_DISPLAY_STATE_NO_DATA, WIDGET_DISPLAY_STATE_ERROR]),
|
||||||
|
type: PropTypes.oneOf(['horizontal', 'vertical']),
|
||||||
|
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoundItemContainer;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"Stub_Wrapper__t2zzO","Wrapper_vertical":"Stub_Wrapper_vertical__QC6bX","Wrapper_horizontal":"Stub_Wrapper_horizontal__TJUoi","ImageWrapper":"Stub_ImageWrapper__T8R_J","ImageWrapper_medium":"Stub_ImageWrapper_medium__SORuu","ImageWrapper_large":"Stub_ImageWrapper_large__HbY9h","Image":"Stub_Image__et7s7","TextWrapper":"Stub_TextWrapper__mHeve","TextWrapper_vertical":"Stub_TextWrapper_vertical__9MKBq","Title":"Stub_Title__bu82q","Title_vertical":"Stub_Title_vertical__RkNBR","Title_medium":"Stub_Title_medium__yHPB1","Title_large":"Stub_Title_large__d9tCM","Subtitle":"Stub_Subtitle__dm_cw","Subtitle_horizontal":"Stub_Subtitle_horizontal__2Jt5h","Subtitle_medium":"Stub_Subtitle_medium__xtfo1","Subtitle_large":"Stub_Subtitle_large__klY9u"};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/imgError.8e71741c.png","height":169,"width":416,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAADCAMAAACZFr56AAAAElBMVEVMaXGnp9+Zpcy3v9qBktV/f6M1we1ZAAAABnRSTlMACyE7PQ6klpETAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAHUlEQVR4nGNgYGBiZYACJhZGBgYGRkYmZmZmRkYAAVwAHnlLK7MAAAAASUVORK5CYII=","blurWidth":8,"blurHeight":3};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default {"src":"/_next/static/media/imgNoData.f0cfbdf7.png","height":186,"width":417,"blurDataURL":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAECAMAAACEE47CAAAAGFBMVEVqarV8bMWAksrd5O7h4+vv7PPKyN7q7PN0o8d6AAAACHRSTlMJARxMg7J/8iXq8DQAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAiSURBVHicY2BkZGRgABGMjEwgGsRjYmNhYgAxmNlZGRgYAAK+AC8YPyUwAAAAAElFTkSuQmCC","blurWidth":8,"blurHeight":4};
|
||||||
132
src/modules/analytics/common/components/Stub/index.jsx
Normal file
132
src/modules/analytics/common/components/Stub/index.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'clsx';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WIDGET_DISPLAY_STATE_DATA,
|
||||||
|
WIDGET_DISPLAY_STATE_ERROR,
|
||||||
|
WIDGET_DISPLAY_STATE_NO_DATA,
|
||||||
|
} from '@/modules/analytics/common/constants';
|
||||||
|
|
||||||
|
import { Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import imgError from './img/imgError.png';
|
||||||
|
import imgNoData from './img/imgNoData.png';
|
||||||
|
|
||||||
|
import styles from './Stub.module.scss';
|
||||||
|
|
||||||
|
function Stub({ secondTitle = null, secondSubTitle = null, ...props }) {
|
||||||
|
const getDisplayImg = {
|
||||||
|
[WIDGET_DISPLAY_STATE_NO_DATA]: imgNoData,
|
||||||
|
[WIDGET_DISPLAY_STATE_ERROR]: imgError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
className={classNames(styles.Wrapper, {
|
||||||
|
[styles.Wrapper_vertical]: props.type === 'vertical',
|
||||||
|
[styles.Wrapper_horizontal]: props.type === 'horizontal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.ImageWrapper, {
|
||||||
|
[styles.ImageWrapper_small]: props.size === 'small',
|
||||||
|
[styles.ImageWrapper_medium]: props.size === 'medium',
|
||||||
|
[styles.ImageWrapper_large]: props.size === 'large',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getDisplayImg[props.imageType]}
|
||||||
|
alt="stub"
|
||||||
|
className={styles.Image}
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
unoptimized
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.TextWrapper, {
|
||||||
|
[styles.TextWrapper_vertical]: props.type === 'vertical',
|
||||||
|
[styles.TextWrapper_horizontal]: props.type === 'horizontal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.Title, {
|
||||||
|
[styles.Title_vertical]: props.type === 'vertical',
|
||||||
|
[styles.Title_horizontal]: props.type === 'horizontal',
|
||||||
|
[styles.Title_small]: props.size === 'small',
|
||||||
|
[styles.Title_medium]: props.size === 'medium',
|
||||||
|
[styles.Title_large]: props.size === 'large',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{props.firstTitle}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{secondTitle && (
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.Title, {
|
||||||
|
[styles.Title_vertical]: props.type === 'vertical',
|
||||||
|
[styles.Title_horizontal]: props.type === 'horizontal',
|
||||||
|
[styles.Title_small]: props.size === 'small',
|
||||||
|
[styles.Title_medium]: props.size === 'medium',
|
||||||
|
[styles.Title_large]: props.size === 'large',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{secondTitle}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.firstSubTitle && (
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.Subtitle, {
|
||||||
|
[styles.Subtitle_vertical]: props.type === 'vertical',
|
||||||
|
[styles.Subtitle_horizontal]: props.type === 'horizontal',
|
||||||
|
[styles.Subtitle_small]: props.size === 'small',
|
||||||
|
[styles.Subtitle_medium]: props.size === 'medium',
|
||||||
|
[styles.Subtitle_large]: props.size === 'large',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{props.firstSubTitle}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secondSubTitle && (
|
||||||
|
<Grid
|
||||||
|
item
|
||||||
|
className={classNames(styles.Subtitle, {
|
||||||
|
[styles.Subtitle_vertical]: props.type === 'vertical',
|
||||||
|
[styles.Subtitle_horizontal]: props.type === 'horizontal',
|
||||||
|
[styles.Subtitle_small]: props.size === 'small',
|
||||||
|
[styles.Subtitle_medium]: props.size === 'medium',
|
||||||
|
[styles.Subtitle_large]: props.size === 'large',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{secondSubTitle}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stub.propTypes = {
|
||||||
|
type: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
|
||||||
|
size: PropTypes.oneOf(['small', 'medium', 'large']).isRequired,
|
||||||
|
imageType: PropTypes.oneOf([WIDGET_DISPLAY_STATE_DATA, WIDGET_DISPLAY_STATE_NO_DATA, WIDGET_DISPLAY_STATE_ERROR])
|
||||||
|
.isRequired,
|
||||||
|
firstTitle: PropTypes.string.isRequired,
|
||||||
|
secondTitle: PropTypes.string,
|
||||||
|
firstSubTitle: PropTypes.string.isRequired,
|
||||||
|
secondSubTitle: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stub;
|
||||||
10
src/modules/analytics/common/components/index.js
Normal file
10
src/modules/analytics/common/components/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import AreaGraph from './AreaGraph';
|
||||||
|
import DialogCalendarRange from './DialogCalendarRange';
|
||||||
|
// info data states
|
||||||
|
import GlobalStateEmpty from './GlobalStateEmpty';
|
||||||
|
import GlobalStateError from './GlobalStateError';
|
||||||
|
import Header from './Header';
|
||||||
|
import Layout from './Layout';
|
||||||
|
import RoundItemContainer from './RoundItemContainer';
|
||||||
|
|
||||||
|
export { Layout, Header, RoundItemContainer, AreaGraph, DialogCalendarRange, GlobalStateEmpty, GlobalStateError };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@/mui/components';
|
||||||
|
|
||||||
|
// Sorry for many !important
|
||||||
|
// This component used in parallel with PCN modules.
|
||||||
|
// PCN uses global styles for MUI and no way to override
|
||||||
|
|
||||||
|
function ConfirmDialog({ cancelButtonText = null, onCancel = null, isOpen = false, ...props }) {
|
||||||
|
useEffect(() => () => props.onClose?.(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
>
|
||||||
|
<DialogTitle onClose={props.onClose} id="alert-dialog-title">
|
||||||
|
{props.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">{props.body}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{cancelButtonText && (
|
||||||
|
<Button color="primary" size="small" variant="outlined" onClick={onCancel || props.onClose}>
|
||||||
|
{cancelButtonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="small" color="primary" onClick={props.onConfirm}>
|
||||||
|
{props.confirmButtonText}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
body: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({})]).isRequired,
|
||||||
|
isOpen: PropTypes.bool,
|
||||||
|
confirmButtonText: PropTypes.string.isRequired,
|
||||||
|
cancelButtonText: PropTypes.string,
|
||||||
|
onConfirm: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onCancel: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"CustomReports_Wrapper__qdynA","Title":"CustomReports_Title__blde6","Controls":"CustomReports_Controls__x_2lE","Header":"CustomReports_Header__T_UZs","Filters":"CustomReports_Filters__jHFIu","Search":"CustomReports_Search__m8eEC","TableWrap":"CustomReports_TableWrap__WhDNM","RowButtons":"CustomReports_RowButtons__0KQEH","Row":"CustomReports_Row__ppZe6","Cell":"CustomReports_Cell__kEUmi","VisibilityHidden":"CustomReports_VisibilityHidden__nrIvc"};
|
||||||
279
src/modules/analytics/customReports/components/index.jsx
Normal file
279
src/modules/analytics/customReports/components/index.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezonePlugin from 'dayjs/plugin/timezone';
|
||||||
|
import utcPlugin from 'dayjs/plugin/utc';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { AccessTime, Add, ContentCopy, DeleteRounded, Download, SearchRounded } from '@mui/icons-material';
|
||||||
|
|
||||||
|
import { NAME_MAX_LENGTH, TABLE_HEADERS } from '../constants';
|
||||||
|
import { SORT_ASC, SORT_DESC } from '@/modules/@common/constants/sort';
|
||||||
|
|
||||||
|
import * as selectors from '../redux/selectors';
|
||||||
|
import * as actions from '../redux/slices';
|
||||||
|
import { getUserEmailSelector, getUserTimezoneSelector } from '@/modules/@common/user/redux/selectors';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TableScroll,
|
||||||
|
TableSortLabel,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './CustomReports.module.scss';
|
||||||
|
|
||||||
|
dayjs.extend(utcPlugin);
|
||||||
|
dayjs.extend(timezonePlugin);
|
||||||
|
|
||||||
|
function CustomReports() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const userTimezone = useSelector(getUserTimezoneSelector);
|
||||||
|
const userEmail = useSelector(getUserEmailSelector);
|
||||||
|
|
||||||
|
const searchText = useSelector(selectors.searchTextSelector);
|
||||||
|
const page = useSelector(selectors.pageSelector);
|
||||||
|
const pageSize = useSelector(selectors.pageSizeSelector);
|
||||||
|
const sortBy = useSelector(selectors.sortBySelector);
|
||||||
|
const sortOrder = useSelector(selectors.sortOrderSelector);
|
||||||
|
const records = useSelector(selectors.recordsSelector);
|
||||||
|
const recordsTotal = useSelector(selectors.recordsTotalSelector);
|
||||||
|
const isDownloading = useSelector(selectors.isDownloadingSelector);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const fetchData = (payload) => {
|
||||||
|
dispatch(actions.getCustomReportsAction(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRowClick = (id) => {
|
||||||
|
router.push(`/custom-reports-new/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (value) => {
|
||||||
|
dispatch(
|
||||||
|
actions.setFieldAction({
|
||||||
|
searchText: value,
|
||||||
|
page: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fetchData(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSorting = (id) => {
|
||||||
|
const isAsc = sortBy === id && sortOrder === 'ASC';
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
actions.setFieldAction({
|
||||||
|
sortOrder: isAsc ? 'DESC' : 'ASC',
|
||||||
|
sortBy: id,
|
||||||
|
page: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.Wrapper}>
|
||||||
|
<Stack direction="column" fullWidth justifyContent="center" className={styles.Title}>
|
||||||
|
<Typography component="div" variant="h6" noWrap>
|
||||||
|
Custom Reports
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
spacing={1}
|
||||||
|
fullWidth
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
className={styles.Controls}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
className={styles.Search}
|
||||||
|
placeholder="Search by report name or owner"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(event) => onSearch(event.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={() => onSearch(searchText)}>
|
||||||
|
<SearchRounded />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={2} justifyContent="flex-end" alignItems="center" direction="row">
|
||||||
|
<Button
|
||||||
|
data-id="add-new-report"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
router.push('/custom-reports-new/new');
|
||||||
|
}}
|
||||||
|
startIcon={<Add />}
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<div className={styles.TableWrap}>
|
||||||
|
<TableContainer>
|
||||||
|
<TableScroll>
|
||||||
|
<Table aria-label="table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{TABLE_HEADERS.map((header, index) => (
|
||||||
|
<TableCell key={`${header.id}-${index + 1}`} className={styles.Cell}>
|
||||||
|
{header.isSortable && (
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === header.id}
|
||||||
|
direction={sortBy === header.id ? sortOrder?.toLowerCase() : SORT_ASC}
|
||||||
|
onClick={() => {
|
||||||
|
handleSorting(header.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
{sortBy === header.id ? (
|
||||||
|
<Box component="span" className={styles.VisibilityHidden}>
|
||||||
|
{sortOrder === SORT_DESC ? 'sorted descending' : 'sorted ascending'}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</TableSortLabel>
|
||||||
|
)}
|
||||||
|
{!header.isSortable && header.label}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{records?.map(({ id, name, createdAt, updatedAt, createdBy, lastDelivery, nextDelivery }) => (
|
||||||
|
<TableRow
|
||||||
|
className={styles.Row}
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
handleRowClick(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className={styles.Cell}>
|
||||||
|
{name?.length > NAME_MAX_LENGTH ? (
|
||||||
|
<Tooltip title={name} placement="top">
|
||||||
|
{`${name.slice(0, NAME_MAX_LENGTH)}...`}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.Cell}>{createdBy}</TableCell>
|
||||||
|
<TableCell className={styles.Cell}>
|
||||||
|
{dayjs(createdAt).tz(userTimezone).format('MMM DD, YYYY, hh:mm A')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.Cell}>
|
||||||
|
{updatedAt ? dayjs(updatedAt).tz(userTimezone).format('MMM DD, YYYY, hh:mm A') : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AccessTime htmlColor={nextDelivery ? 'success.main' : 'error.main'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.Cell}>
|
||||||
|
{lastDelivery ? dayjs(lastDelivery).tz(userTimezone).format('MMM DD, YYYY, hh:mm A') : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.Cell}>
|
||||||
|
{nextDelivery ? dayjs(nextDelivery).tz(userTimezone).format('MMM DD, YYYY, hh:mm A') : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className={styles.RowButtons}>
|
||||||
|
{userEmail === createdBy && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dispatch(
|
||||||
|
actions.runDownloadReportAction({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
requestFullInfo: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/custom-reports-new/${id}?duplicate=true`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentCopy />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{userEmail === createdBy && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dispatch(actions.deleteCustomReportAction({ id }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteRounded />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableScroll>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
rowsPerPageOptions={[10, 25, 50]}
|
||||||
|
count={recordsTotal}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(event, newPage) => {
|
||||||
|
dispatch(actions.setFieldAction({ page: newPage }));
|
||||||
|
fetchData();
|
||||||
|
}}
|
||||||
|
onRowsPerPageChange={(event) => {
|
||||||
|
dispatch(
|
||||||
|
actions.setFieldAction({
|
||||||
|
pageSize: +event.target.value,
|
||||||
|
page: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fetchData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomReports;
|
||||||
3
src/modules/analytics/customReports/index.jsx
Normal file
3
src/modules/analytics/customReports/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import AnalyticsAdPerformanceDashboard from './components';
|
||||||
|
|
||||||
|
export default AnalyticsAdPerformanceDashboard;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Failure":"Failure_Failure__OknnO","FailureHeader":"Failure_FailureHeader__hI_wi","FailureText":"Failure_FailureText__DGySc"};
|
||||||
20
src/modules/analytics/general/components/Failure/index.jsx
Normal file
20
src/modules/analytics/general/components/Failure/index.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './Failure.module.scss';
|
||||||
|
|
||||||
|
function Failure() {
|
||||||
|
return (
|
||||||
|
<Grid container justifyContent="center" alignItems="center" direction="column" className={styles.Failure}>
|
||||||
|
<Grid item>
|
||||||
|
<h2 className={styles.FailureHeader}>Analytics is under maintenance.</h2>
|
||||||
|
</Grid>
|
||||||
|
<Grid item className={styles.FailureText}>
|
||||||
|
<span>Please contact your account manager for further information.</span>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Failure;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Wrapper":"General_Wrapper__i_2Ph","Iframe":"General_Iframe__5xFcc"};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"SelectAccountWrapper":"InfoNeedSelectAccount_SelectAccountWrapper__Rqb_z","Logo":"InfoNeedSelectAccount_Logo__gfgnv","InfoText":"InfoNeedSelectAccount_InfoText__50EX6"};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
import { Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import logo from '@/assets/img/logo.png';
|
||||||
|
|
||||||
|
import styles from './InfoNeedSelectAccount.module.scss';
|
||||||
|
|
||||||
|
function InfoNeedSelectAccount() {
|
||||||
|
return (
|
||||||
|
<Grid container alignContent="space-around" justifyContent="center">
|
||||||
|
<Grid item className={styles.SelectAccountWrapper}>
|
||||||
|
<Image src={logo} alt="logo" className={styles.Logo} width={200} height={50} />
|
||||||
|
<div className={styles.InfoText}>Select an Account</div>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoNeedSelectAccount;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// extracted by mini-css-extract-plugin
|
||||||
|
module.exports = {"Loader":"Loader_Loader__LNQ0B","ProgressRoot":"Loader_ProgressRoot__7ug0y","LoaderText":"Loader_LoaderText__PXXSd"};
|
||||||
24
src/modules/analytics/general/components/Loader/index.jsx
Normal file
24
src/modules/analytics/general/components/Loader/index.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { CircularProgress, Grid } from '@/mui/components';
|
||||||
|
|
||||||
|
import styles from './Loader.module.scss';
|
||||||
|
|
||||||
|
function Loader() {
|
||||||
|
return (
|
||||||
|
<Grid container justifyContent="center" alignItems="center" className={styles.Loader}>
|
||||||
|
<Grid item>
|
||||||
|
<CircularProgress
|
||||||
|
classes={{
|
||||||
|
root: styles.ProgressRoot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item className={styles.LoaderText}>
|
||||||
|
<span>Loading Dashboard...</span>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loader;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user