diff --git a/package-lock.json b/package-lock.json index f269fa09..41c187d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-html": "^0.2.3", "@types/google.analytics": "0.0.40", + "@types/gtag.js": "^0.0.10", "@types/howler": "^2.2.4", "@types/js-yaml": "^4.0.4", "@types/lz-string": "^1.3.34", @@ -594,6 +595,12 @@ "integrity": "sha512-R3HpnLkqmKxhUAf8kIVvDVGJqPtaaZlW4yowNwjOZUTmYUQEgHh8Nh5wkSXKMroNAuQM8gbXJHmNbbgA8tdb7Q==", "dev": true }, + "node_modules/@types/gtag.js": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.10.tgz", + "integrity": "sha512-98Hy7woUb3jMAMXkZQwfIOYNyfxmI0+U4m0PpCGdnd/FHk0tDpQFCqgXdNkdEoXsKkcGya/2Gew1cAJjKJspVw==", + "dev": true + }, "node_modules/@types/howler": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.4.tgz", @@ -5584,6 +5591,12 @@ "integrity": "sha512-R3HpnLkqmKxhUAf8kIVvDVGJqPtaaZlW4yowNwjOZUTmYUQEgHh8Nh5wkSXKMroNAuQM8gbXJHmNbbgA8tdb7Q==", "dev": true }, + "@types/gtag.js": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.10.tgz", + "integrity": "sha512-98Hy7woUb3jMAMXkZQwfIOYNyfxmI0+U4m0PpCGdnd/FHk0tDpQFCqgXdNkdEoXsKkcGya/2Gew1cAJjKJspVw==", + "dev": true + }, "@types/howler": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.4.tgz", diff --git a/package.json b/package.json index 614ff1f4..c18bd593 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-html": "^0.2.3", "@types/google.analytics": "0.0.40", + "@types/gtag.js": "^0.0.10", "@types/howler": "^2.2.4", "@types/js-yaml": "^4.0.4", "@types/lz-string": "^1.3.34", diff --git a/src/app/Analytics.ts b/src/app/Analytics.ts index 440620ab..2a1a30c9 100644 --- a/src/app/Analytics.ts +++ b/src/app/Analytics.ts @@ -1,4 +1,10 @@ +import type { VersionId } from './services' + +type Method = 'menu' | 'hotkey' + export namespace Analytics { + + /** Universal Analytics */ const ID_SITE = 'Site' const ID_GENERATOR = 'Generator' @@ -22,19 +28,40 @@ export namespace Analytics { ga('send', 'pageview') } - export function setLanguage(language: string) { - dimension(DIM_LANGUAGE, language) - event(ID_SITE, 'set-language', language) + /** + * @deprecated + */ + export function generatorEvent(action: string, label?: string) { + event(ID_GENERATOR, action, label) + } + + function legacyMethod(method: Method) { + return method === 'menu' ? 'Menu' : 'Hotkey' + } + /** END Universal Analytics 4 */ + + export function setLocale(locale: string) { + dimension(DIM_LANGUAGE, locale) + event(ID_SITE, 'set-language', locale) + gtag('event', 'use_locale', { + locale, + }) } export function setTheme(theme: string) { dimension(DIM_THEME, theme) event(ID_SITE, 'set-theme', theme) + gtag('event', 'use_theme', { + theme, + }) } export function setVersion(version: string) { dimension(DIM_VERSION, version) event(ID_GENERATOR, 'set-version', version) + gtag('event', 'use_version', { + version, + }) } export function setPreview(preview: string) { @@ -42,15 +69,134 @@ export namespace Analytics { event(ID_GENERATOR, 'set-preview', preview) } - export function setGenerator(generator: string) { - dimension(DIM_GENERATOR, generator) + export function setGenerator(file_type: string) { + dimension(DIM_GENERATOR, file_type) + gtag('event', 'use_generator', { + file_type, + }) } - export function setPrefersColorScheme(colorScheme: string) { - dimension(DIM_PREFERS_COLOR_SCHEME, colorScheme) + export function setPrefersColorScheme(color_scheme: string) { + dimension(DIM_PREFERS_COLOR_SCHEME, color_scheme) + gtag('event', 'prefers_color_scheme', { + color_scheme, + }) } - export function generatorEvent(action: string, label?: string) { - event(ID_GENERATOR, action, label) + export function resetGenerator(file_type: string, history: number, method: Method) { + event(ID_GENERATOR, 'reset') + gtag('event', 'reset_generator', { + file_type, + history, + method, + }) + } + + export function undoGenerator(file_type: string, history: number, method: Method) { + event(ID_GENERATOR, 'undo', legacyMethod(method)) + gtag('event', 'undo_generator', { + file_type, + history, + method, + }) + } + + export function redoGenerator(file_type: string, history: number, method: Method) { + event(ID_GENERATOR, 'undo', legacyMethod(method)) + gtag('event', 'redo_generator', { + file_type, + history, + method, + }) + } + + export function saveProjectFile(file_type: string, project_size: number, projects_count: number, method: Method) { + event(ID_GENERATOR, 'save-project-file', legacyMethod(method)) + gtag('event', 'save_project_file', { + file_type, + project_size, + projects_count, + method, + }) + } + + export function loadPreset(file_type: string, file_name: string) { + event(ID_GENERATOR, 'load-preset', file_name) + gtag('event', 'load_generator_preset', { + file_type, + file_name, + }) + } + + export function openPreset(file_type: string, file_name: string) { + gtag('event', 'open_generator_preset', { + file_type, + file_name, + }) + } + + export function createSnippet(file_type: string, snippet_id: string, version: VersionId, data_size: number, compressed_size: number, compression_rate: number) { + gtag('event', 'create_generator_snippet', { + file_type, + snippet_id, + version, + data_size, + compressed_size, + compression_rate, + }) + } + + export function openSnippet(file_type: string, snippet_id: string, version: VersionId) { + gtag('event', 'open_generator_snippet', { + file_type, + snippet_id, + version, + }) + } + + export function copyOutput(file_type: string, method: Method) { + gtag('event', 'copy_generator_output', { + file_type, + method, + }) + } + + export function downloadOutput(file_type: string, method: Method) { + gtag('event', 'download_generator_output', { + file_type, + method, + }) + } + + export function showOutput(file_type: string, method: Method) { + event(ID_GENERATOR, 'toggle-output', 'visible') + gtag('event', 'show_generator_output', { + file_type, + method, + }) + } + + export function hideOutput(file_type: string, method: Method) { + event(ID_GENERATOR, 'toggle-output', 'hidden') + gtag('event', 'hide_generator_output', { + file_type, + method, + }) + } + + export function showPreview(file_type: string, method: Method) { + event(ID_GENERATOR, 'toggle-preview', 'visible') + gtag('event', 'show_generator_preview', { + file_type, + method, + }) + } + + export function hidePreview(file_type: string, method: Method) { + event(ID_GENERATOR, 'toggle-preview', 'hidden') + gtag('event', 'hide_generator_preview', { + file_type, + method, + }) } } diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 22dca404..5814bea0 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -12,7 +12,7 @@ const Themes: Record = { } export function Header() { - const { lang, locale, changeLanguage } = useLocale() + const { lang, locale, changeLocale: changeLanguage } = useLocale() const { theme, changeTheme } = useTheme() const { version } = useVersion() const { title } = useTitle() diff --git a/src/app/contexts/Locale.tsx b/src/app/contexts/Locale.tsx index 64d6d3c7..ad356451 100644 --- a/src/app/contexts/Locale.tsx +++ b/src/app/contexts/Locale.tsx @@ -9,12 +9,12 @@ import { Store } from '../Store' interface Locale { lang: string, locale: (key: string, ...params: string[]) => string, - changeLanguage: (lang: string) => unknown, + changeLocale: (lang: string) => unknown, } const Locale = createContext({ lang: 'none', locale: key => key, - changeLanguage: () => {}, + changeLocale: () => {}, }) export const Locales: { @@ -59,9 +59,9 @@ export function LocaleProvider({ children }: { children: ComponentChildren }) { return localize(lang, key, ...params) }, [lang]) - const changeLanguage = useCallback(async (lang: string) => { + const changeLocale = useCallback(async (lang: string) => { await loadLocale(lang) - Analytics.setLanguage(lang) + Analytics.setLocale(lang) Store.setLanguage(lang) setLanguage(lang) }, []) @@ -79,8 +79,8 @@ export function LocaleProvider({ children }: { children: ComponentChildren }) { const value: Locale = { lang, - locale: locale, - changeLanguage, + locale, + changeLocale, } return diff --git a/src/app/contexts/Project.tsx b/src/app/contexts/Project.tsx index 0fcfa4e4..57ee9fd3 100644 --- a/src/app/contexts/Project.tsx +++ b/src/app/contexts/Project.tsx @@ -26,6 +26,7 @@ export type ProjectFile = { } interface ProjectContext { + projects: Project[], project: Project, file?: ProjectFile, changeProject: (name: string) => unknown, @@ -35,6 +36,7 @@ interface ProjectContext { closeFile: () => unknown, } const Project = createContext({ + projects: [DRAFT_PROJECT], project: DRAFT_PROJECT, changeProject: () => {}, updateProject: () => {}, @@ -105,6 +107,7 @@ export function ProjectProvider({ children }: { children: ComponentChildren }) { }, []) const value: ProjectContext = { + projects, project, file, changeProject: setProjectName, diff --git a/src/app/pages/Generator.tsx b/src/app/pages/Generator.tsx index eeac48bc..ce27f8d4 100644 --- a/src/app/pages/Generator.tsx +++ b/src/app/pages/Generator.tsx @@ -8,17 +8,19 @@ import { useLocale, useProject, useTitle, useVersion } from '../contexts' import { AsyncCancel, useActiveTimeout, useAsync, useModel, useSearchParam } from '../hooks' import { getOutput } from '../schema/transformOutput' import type { VersionId } from '../services' -import { checkVersion, fetchPreset, getBlockStates, getCollections, getModel, getSnippet, shareSnippet, SHARE_KEY } from '../services' +import { checkVersion, fetchPreset, getBlockStates, getCollections, getModel, getSnippet, shareSnippet } from '../services' import { Store } from '../Store' import { cleanUrl, deepEqual, getGenerator } from '../Utils' +export const SHARE_KEY = 'share' + interface Props { default?: true, } export function Generator({}: Props) { const { locale } = useLocale() const { version, changeVersion, changeTargetVersion } = useVersion() - const { project, file, updateFile, openFile, closeFile } = useProject() + const { projects, project, file, updateFile, openFile, closeFile } = useProject() const [error, setError] = useState(null) const [errorBoundary, errorRetry] = useErrorBoundary() if (errorBoundary) { @@ -87,6 +89,7 @@ export function Generator({}: Props) { setPreviewShown(true) setSourceShown(false) } + Analytics.openSnippet(gen.id, sharedSnippetId, version) data = snippet.data } const [model, blockStates] = await Promise.all([ @@ -160,26 +163,26 @@ export function Generator({}: Props) { }, [file, model]) const reset = () => { - Analytics.generatorEvent('reset') + Analytics.resetGenerator(gen.id, model?.historyIndex ?? 1, 'menu') model?.reset(DataModel.wrapLists(model.schema.default()), true) } const undo = (e: MouseEvent) => { e.stopPropagation() - Analytics.generatorEvent('undo', 'Menu') + Analytics.undoGenerator(gen.id, (model?.historyIndex ?? 1) - 1, 'menu') model?.undo() } const redo = (e: MouseEvent) => { e.stopPropagation() - Analytics.generatorEvent('redo', 'Menu') + Analytics.redoGenerator(gen.id, (model?.historyIndex ?? 1) + 1, 'menu') model?.redo() } const onKeyUp = (e: KeyboardEvent) => { if (e.ctrlKey && e.key === 'z') { - Analytics.generatorEvent('undo', 'Hotkey') + Analytics.undoGenerator(gen.id, (model?.historyIndex ?? 1) - 1, 'hotkey') model?.undo() } else if (e.ctrlKey && e.key === 'y') { - Analytics.generatorEvent('redo', 'Hotkey') + Analytics.redoGenerator(gen.id, (model?.historyIndex ?? 1) + 1, 'hotkey') model?.redo() } } @@ -187,7 +190,7 @@ export function Generator({}: Props) { if (e.ctrlKey && e.key === 's') { e.preventDefault() if (model && blockStates && file) { - Analytics.generatorEvent('save', 'Hotkey') + Analytics.saveProjectFile(gen.id, project.files.length, projects.length, 'hotkey') const data = getOutput(model, blockStates) updateFile(gen.id, file?.id, { id: file?.id, data }) setDirty(false) @@ -213,7 +216,7 @@ export function Generator({}: Props) { }, [version, gen.id]) const selectPreset = (id: string) => { - Analytics.generatorEvent('load-preset', id) + Analytics.loadPreset(gen.id, id) setSharedSnippetId(undefined, true) changeTargetVersion(version, true) setCurrentPreset(id) @@ -260,7 +263,9 @@ export function Generator({}: Props) { setShareShown(true) } else { shareSnippet(gen.id, version, output, previewShown) - .then(url => { + .then(({ id, length, compressed, rate }) => { + Analytics.createSnippet(gen.id, id, version, length, compressed, rate) + const url = `${location.origin}/${gen.url}/?${SHARE_KEY}=${id}` setShareUrl(url) setShareShown(true) }) @@ -289,11 +294,11 @@ export function Generator({}: Props) { const [doImport, setImport] = useState(0) const copySource = () => { - Analytics.generatorEvent('copy') + Analytics.copyOutput(gen.id, 'menu') setCopy(doCopy + 1) } const downloadSource = () => { - Analytics.generatorEvent('download') + Analytics.downloadOutput(gen.id, 'menu') setDownload(doDownload + 1) } const importSource = () => { @@ -302,7 +307,11 @@ export function Generator({}: Props) { setImport(doImport + 1) } const toggleSource = () => { - Analytics.generatorEvent('toggle-output', !sourceShown ? 'visible' : 'hidden') + if (sourceShown) { + Analytics.hideOutput(gen.id, 'menu') + } else { + Analytics.showOutput(gen.id, 'menu') + } setSourceShown(!sourceShown) setCopy(0) setDownload(0) @@ -319,7 +328,11 @@ export function Generator({}: Props) { if (sourceShown) actionsShown += 2 const togglePreview = () => { - Analytics.generatorEvent('toggle-preview', !previewShown ? 'visible' : 'hidden') + if (sourceShown) { + Analytics.hidePreview(gen.id, 'menu') + } else { + Analytics.showPreview(gen.id, 'menu') + } setPreviewShown(!previewShown) if (!previewShown && sourceShown) { setSourceShown(false) diff --git a/src/app/services/Sharing.ts b/src/app/services/Sharing.ts index 3739bdb7..2c56e941 100644 --- a/src/app/services/Sharing.ts +++ b/src/app/services/Sharing.ts @@ -1,17 +1,15 @@ import lz from 'lz-string' -import config from '../../config.json' import type { VersionId } from './Schemas' const API_PREFIX = 'https://z15g7can.directus.app/items' -export const SHARE_KEY = 'share' const ShareCache = new Map() export async function shareSnippet(type: string, version: VersionId, jsonData: any, show_preview: boolean) { try { - const data = lz.compressToBase64(JSON.stringify(jsonData)) - const raw = btoa(JSON.stringify(jsonData)) - console.log('Compression rate', raw.length / data.length) + const raw = JSON.stringify(jsonData) + const data = lz.compressToBase64(raw) + console.log('Compression rate', raw.length / raw.length) const body = JSON.stringify({ data, type, version, show_preview }) let id = ShareCache.get(body) if (!id) { @@ -19,8 +17,7 @@ export async function shareSnippet(type: string, version: VersionId, jsonData: a ShareCache.set(body, snippet.id) id = snippet.id as string } - const gen = config.generators.find(g => g.id === type)! - return `${location.protocol}//${location.host}/${gen.url}/?${SHARE_KEY}=${id}` + return { id, length: raw.length, compressed: data.length, rate: raw.length / data.length } } catch (e) { if (e instanceof Error) { e.message = `Error creating share link: ${e.message}`