├── .clasp.json ├── .github └── workflows │ ├── prod-env-deploy.yml │ └── test-env-deploy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── README.md ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── postBuildProcess.js ├── postcss.config.js ├── rollup.config.npm.js ├── src ├── App.svelte ├── app.css ├── appsscript.json ├── components │ ├── AddAdminModal.svelte │ ├── HeaderBar.svelte │ ├── InitialLoad.svelte │ ├── LoadingSpinner.svelte │ ├── Modal.svelte │ ├── NavLink.svelte │ ├── Panel.svelte │ ├── ProtectedRoute.svelte │ ├── RemoveAdminModal.svelte │ ├── Toast.svelte │ ├── Toaster.svelte │ └── UserSelect.svelte ├── lib │ ├── GAS_API.js │ ├── fetchAppConfig.js │ ├── mocks │ │ ├── _API.ts │ │ ├── api │ │ │ ├── getAppConfiguration.ts │ │ │ ├── getUser.ts │ │ │ ├── getViewConfiguration.ts │ │ │ ├── getViewData.ts │ │ │ ├── putAppConfiguration.ts │ │ │ └── putUser.ts │ │ ├── data │ │ │ ├── appConfiguration.ts │ │ │ ├── dataSourceConfigurations.ts │ │ │ ├── mockData.ts │ │ │ ├── user.ts │ │ │ └── viewConfigurations.ts │ │ ├── query.ts │ │ └── sleep.ts │ ├── polyfillScriptRun.js │ └── sanitizeEmail.ts ├── main.js ├── routes │ ├── 404.svelte │ ├── Home.svelte │ ├── Profile.svelte │ ├── Settings.svelte │ ├── UserPreferences.svelte │ └── View.svelte ├── server │ ├── api │ │ ├── getAppConfiguration.ts │ │ ├── getUser.ts │ │ ├── putAppConfiguration.ts │ │ └── putUser.ts │ ├── doGet.js │ ├── env.ts │ ├── lib │ │ ├── createUser_.ts │ │ ├── getAdmins_.ts │ │ ├── getDeploymentDomain_.ts │ │ ├── initializeApp_.ts │ │ ├── loadAppConfiguration_.ts │ │ ├── loadUserProfileImageUrl_.ts │ │ └── query.js │ └── noAuth.html ├── stores.ts ├── types │ └── schemas.ts └── vite-env.d.ts ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.js /.clasp.json: -------------------------------------------------------------------------------- 1 | {"rootDir":"dist","scriptId":"1JIjJPR-KKIYPrBK92j-meWZNw6qvDLnFeJcG1-t2QwedzYMIAacjkBhe"} -------------------------------------------------------------------------------- /.github/workflows/prod-env-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Prod Env Deploy Script 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build_and_deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Node 17 | uses: actions/setup-node@v3 18 | 19 | - name: Install dependencies 20 | uses: bahmutov/npm-install@v1 21 | 22 | - name: Install clasp 23 | id: install-clasp 24 | run: sudo npm install @google/clasp@2.4.1 -g 25 | 26 | - name: Write CLASPRC_JSON secret to .clasprc.json file 27 | id: write-clasprc 28 | run: echo "$CLASPRC_JSON_SECRET" >> ~/.clasprc.json 29 | env: 30 | CLASPRC_JSON_SECRET: ${{ secrets.CLASPRC_JSON }} 31 | 32 | - name: Check clasp login status 33 | id: clasp_login 34 | run: clasp login --status 35 | 36 | - name: Save current .clasprc.json contents to CLASPRC_JSON_FILE environment variable 37 | id: save-clasprc 38 | run: | 39 | echo ::add-mask::$(tr -d '\n\r' < ~/.clasprc.json) 40 | echo "CLASPRC_JSON_FILE=$(tr -d '\n\r' < ~/.clasprc.json)" >> $GITHUB_ENV 41 | 42 | - name: Save CLASPRC_JSON_FILE environment variable to CLASPRC_JSON repo secret 43 | id: set-clasprc-secret 44 | if: ${{ env.CLASPRC_JSON_FILE != env.CLASPRC_JSON_SECRET }} 45 | uses: hmanzur/actions-set-secret@v2.0.0 46 | env: 47 | CLASPRC_JSON_SECRET: ${{ secrets.CLASPRC_JSON }} 48 | with: 49 | name: "CLASPRC_JSON" 50 | value: ${{ env.CLASPRC_JSON_FILE }} 51 | repository: ${{ github.repository }} 52 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 53 | 54 | - name: Set scriptId in .clasp.json file 55 | id: set-script-id 56 | run: jq '.scriptId = "${{env.PROD_SCRIPT_ID}}"' .clasp.json > /tmp/.clasp.json && mv /tmp/.clasp.json .clasp.json 57 | env: 58 | PROD_SCRIPT_ID: ${{secrets.PROD_SCRIPT_ID}} 59 | 60 | ## Build & push 61 | 62 | - name: Build project 63 | run: npm run build 64 | 65 | - name: Push script to scripts.google.com 66 | id: clasp-push 67 | run: clasp push -f 68 | 69 | - name: Deploy Script 70 | id: clasp-deploy 71 | if: github.ref == 'refs/heads/main' 72 | run: clasp deploy -i "${{ secrets.PROD_DEPLOYMENT_ID }}" -d ${{github.run_number}} 73 | env: 74 | PROD_SCRIPT_ID: ${{ secrets.PROD_SCRIPT_ID }} 75 | -------------------------------------------------------------------------------- /.github/workflows/test-env-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test Env Deploy Script 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [test] 7 | 8 | jobs: 9 | build_and_deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup Node 17 | uses: actions/setup-node@v3 18 | 19 | - name: Install dependencies 20 | uses: bahmutov/npm-install@v1 21 | 22 | - name: Install clasp 23 | id: install-clasp 24 | run: sudo npm install @google/clasp@2.4.1 -g 25 | 26 | - name: Write CLASPRC_JSON secret to .clasprc.json file 27 | id: write-clasprc 28 | run: echo "$CLASPRC_JSON_SECRET" >> ~/.clasprc.json 29 | env: 30 | CLASPRC_JSON_SECRET: ${{ secrets.CLASPRC_JSON }} 31 | 32 | - name: Check clasp login status 33 | id: clasp_login 34 | run: clasp login --status 35 | 36 | - name: Save current .clasprc.json contents to CLASPRC_JSON_FILE environment variable 37 | id: save-clasprc 38 | run: | 39 | echo ::add-mask::$(tr -d '\n\r' < ~/.clasprc.json) 40 | echo "CLASPRC_JSON_FILE=$(tr -d '\n\r' < ~/.clasprc.json)" >> $GITHUB_ENV 41 | 42 | - name: Save CLASPRC_JSON_FILE environment variable to CLASPRC_JSON repo secret 43 | id: set-clasprc-secret 44 | if: ${{ env.CLASPRC_JSON_FILE != env.CLASPRC_JSON_SECRET }} 45 | uses: hmanzur/actions-set-secret@v2.0.0 46 | env: 47 | CLASPRC_JSON_SECRET: ${{ secrets.CLASPRC_JSON }} 48 | with: 49 | name: "CLASPRC_JSON" 50 | value: ${{ env.CLASPRC_JSON_FILE }} 51 | repository: ${{ github.repository }} 52 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 53 | 54 | - name: Set scriptId in .clasp.json file 55 | id: set-script-id 56 | run: jq '.scriptId = "${{env.TEST_SCRIPT_ID}}"' .clasp.json > /tmp/.clasp.json && mv /tmp/.clasp.json .clasp.json 57 | env: 58 | TEST_SCRIPT_ID: ${{secrets.TEST_SCRIPT_ID}} 59 | 60 | ## Build & push 61 | 62 | - name: Build project 63 | run: npm run build 64 | 65 | - name: Push script to scripts.google.com 66 | id: clasp-push 67 | run: clasp push -f 68 | 69 | - name: Deploy Script 70 | id: clasp-deploy 71 | if: github.ref == 'refs/heads/test' 72 | run: clasp deploy -i "${{ secrets.TEST_DEPLOYMENT_ID }}" -d ${{github.run_number}} 73 | env: 74 | TEST_SCRIPT_ID: ${{ secrets.TEST_SCRIPT_ID }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Clasp config 27 | #**/.clasp.json 28 | 29 | # Environment variables 30 | .env 31 | 32 | # temporarily adding to ignore image assets 33 | images/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Clasp config 27 | **/.clasp.json 28 | 29 | # Environment variables 30 | .env -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["svelte.svelte-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This is a proof-of-concept project to create a template for new web 4 | applications that run on Google Apps Script. It uses... 5 | 6 | - [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) for the front end 7 | - [DaisyUI] / [TailwindCSS] for components & styling 8 | - [Google Apps Script] for the back end 9 | - [JSDoc] type definitions 10 | - [Vite] for the build process 11 | 12 | Whereas a "boilerplate" may produce the bare minimum to get a new web app off 13 | the ground, this project provides a few extra opinions, UI elements, type definitions, etc that I would consider useful for most applications, rather than just an app skeleton. 14 | 15 | # How to Use 16 | 17 | # Other Notable Dependncies 18 | 19 | - [clasp] 20 | - [clasp.env] 21 | 22 | # Features 23 | 24 | 25 | # Critical Decisions 26 | 27 | ``` 28 | "timeZone": "America/New_York" 29 | ``` 30 | ``` 31 | "webapp": { 32 | "executeAs": "USER_DEPLOYING" vs "executeAs": "USER_ACCESSING", 33 | "access": "ANYONE" vs "access": "ANYONE_ANONYMOUS" 34 | } 35 | ``` 36 | 37 | ## Recommended IDE Setup 38 | 39 | [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) 40 | 41 | ## Technical considerations 42 | 43 | **Why use this over SvelteKit?** 44 | 45 | - It brings its own routing solution which might not be preferable for some users. 46 | - It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. 47 | 48 | This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. 49 | 50 | Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. 51 | 52 | **Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** 53 | 54 | Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. 55 | 56 | **Why include `.vscode/extensions.json`?** 57 | 58 | Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. 59 | 60 | **Why enable `checkJs` in the JS template?** 61 | 62 | It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. 63 | 64 | **Why is HMR not preserving my local component state?** 65 | 66 | HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). 67 | 68 | If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. 69 | 70 | ```js 71 | // store.js 72 | // An extremely simple external store 73 | import { writable } from 'svelte/store' 74 | export default writable(0) 75 | ``` 76 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "bundler", 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | /** 7 | * svelte-preprocess cannot figure out whether you have 8 | * a value or a type, so tell TypeScript to enforce using 9 | * `import type` instead of `import` for Types. 10 | */ 11 | "verbatimModuleSyntax": true, 12 | "isolatedModules": true, 13 | "resolveJsonModule": true, 14 | /** 15 | * To have warnings / errors of the Svelte compiler at the 16 | * correct position, enable source maps by default. 17 | */ 18 | "sourceMap": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | /** 22 | * Typecheck JS in `.svelte` and `.js` files by default. 23 | * Disable this if you'd like to use dynamic types. 24 | */ 25 | "checkJs": true 26 | }, 27 | /** 28 | * Use global.d.ts instead of compilerOptions.types 29 | * to avoid limiting type declarations. 30 | */ 31 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"] 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gas-svelte-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "rollup:npm": "rollup -c rollup.config.npm.js", 9 | "build": "vite build && npm run rollup:npm && node postBuildProcess.js", 10 | "build:push": "npm run build && clasp push", 11 | "build:css:watch": "npx tailwindcss -i ./src/input.css -o ./src/output.css --watch", 12 | "preview": "vite preview", 13 | "lint": "npx eslint src", 14 | "lint:fix": "npm run lint -- --fix", 15 | "prettier": "npx prettier src --check", 16 | "prettier:fix": "npm run prettier -- --write", 17 | "format": "npm run prettier:fix", 18 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest" 19 | }, 20 | "lint-staged": { 21 | "*.{js,ts,svelte,html,css}": "prettier --write" 22 | }, 23 | "devDependencies": { 24 | "@rollup/plugin-commonjs": "^25.0.4", 25 | "@rollup/plugin-node-resolve": "^15.2.1", 26 | "@sveltejs/vite-plugin-svelte": "^2.4.2", 27 | "autoprefixer": "^10.4.14", 28 | "daisyui": "^3.1.9", 29 | "jest": "^29.5.0", 30 | "lint-staged": "^14.0.1", 31 | "postcss": "^8.4.25", 32 | "prettier": "^2.8.8", 33 | "prettier-plugin-tailwindcss": "^0.4.1", 34 | "rollup": "^3.28.1", 35 | "rollup-plugin-copy": "^3.4.0", 36 | "rollup-plugin-delete": "^2.0.0", 37 | "svelte": "^4.0.3", 38 | "svelte-routing": "^1.11.0", 39 | "tailwindcss": "^3.3.2", 40 | "typescript": "^5.2.2", 41 | "vite": "^4.4.0", 42 | "vite-plugin-singlefile": "^0.13.5" 43 | }, 44 | "dependencies": { 45 | "@types/google-apps-script": "^1.0.64", 46 | "lodash": "^4.17.21", 47 | "zod": "^3.22.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postBuildProcess.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | function processDir(dir) { 5 | const files = fs.readdirSync(dir); 6 | 7 | for (const file of files) { 8 | const filePath = path.join(dir, file); 9 | 10 | if (fs.statSync(filePath).isDirectory()) { 11 | processDir(filePath); 12 | } else if (filePath.endsWith('.ts')) { 13 | let content = fs.readFileSync(filePath, 'utf8'); 14 | // Remove import statements 15 | content = content.replace(/^import .+ from .+;$/gm, ''); 16 | // Remove export statements or comment them out 17 | content = content.replace(/^export .+;$/gm, ''); 18 | fs.writeFileSync(filePath, content, 'utf8'); 19 | } 20 | } 21 | } 22 | 23 | processDir('./dist/server'); -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /rollup.config.npm.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | 4 | export default { 5 | input: 'node_modules/zod/lib/index.js', 6 | output: { 7 | file: 'dist/server/lib/zod-bundle.js', 8 | format: 'iife', 9 | name: 'z', 10 | }, 11 | plugins: [commonjs(), resolve()], 12 | }; 13 | -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 | 76 | {#if !initialLoadComplete} 77 | 78 | {:else} 79 |
80 | 87 |
88 | 89 | 90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 |
108 |
109 | 110 |
175 |
176 | {/if} 177 |
178 | 179 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | a:hover { 27 | color: #535bf2; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | display: flex; 33 | place-items: center; 34 | min-width: 320px; 35 | min-height: 100vh; 36 | } 37 | 38 | h1 { 39 | font-size: 3.2em; 40 | line-height: 1.1; 41 | } 42 | 43 | .card { 44 | padding: 2em; 45 | } 46 | 47 | #app { 48 | max-width: 1280px; 49 | margin: 0 auto; 50 | padding: 2rem; 51 | text-align: center; 52 | } 53 | 54 | button { 55 | border-radius: 8px; 56 | border: 1px solid transparent; 57 | padding: 0.6em 1.2em; 58 | font-size: 1em; 59 | font-weight: 500; 60 | font-family: inherit; 61 | background-color: #1a1a1a; 62 | cursor: pointer; 63 | transition: border-color 0.25s; 64 | } 65 | button:hover { 66 | border-color: #646cff; 67 | } 68 | button:focus, 69 | button:focus-visible { 70 | outline: 4px auto -webkit-focus-ring-color; 71 | } 72 | 73 | @media (prefers-color-scheme: light) { 74 | :root { 75 | color: #213547; 76 | background-color: #ffffff; 77 | } 78 | a:hover { 79 | color: #747bff; 80 | } 81 | button { 82 | background-color: #f9f9f9; 83 | } 84 | } */ 85 | -------------------------------------------------------------------------------- /src/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | "enabledAdvancedServices": [ 5 | { 6 | "userSymbol": "People", 7 | "version": "v1", 8 | "serviceId": "peopleapi" 9 | } 10 | ] 11 | }, 12 | "exceptionLogging": "STACKDRIVER", 13 | "runtimeVersion": "V8", 14 | "webapp": { 15 | "executeAs": "USER_DEPLOYING", 16 | "access": "ANYONE" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/AddAdminModal.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 |
50 |

Select a user to make an admin

51 | (selectedUsersFromChild = e.detail)} /> 52 |
53 | 59 |
60 | -------------------------------------------------------------------------------- /src/components/HeaderBar.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 43 | 44 | 68 | -------------------------------------------------------------------------------- /src/components/InitialLoad.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /src/components/Modal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 22 | -------------------------------------------------------------------------------- /src/components/NavLink.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
  • 9 | 10 | 11 | 12 |
  • 13 | -------------------------------------------------------------------------------- /src/components/Panel.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
    8 |
    9 |

    {title}

    10 | 11 |
    12 |
    13 | {#if $isLoading} 14 |
    17 | {/if} 18 | 19 |
    20 |
    21 | 22 |
    23 |
    24 |
    25 | -------------------------------------------------------------------------------- /src/components/ProtectedRoute.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {#if $userIsAdmin} 10 | 11 | 12 | 13 | {:else} 14 | 15 | 16 | 17 | {/if} 18 | -------------------------------------------------------------------------------- /src/components/RemoveAdminModal.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 | 50 |
    51 |

    52 | Are you sure you want to remove this user's admin privileges 53 |

    54 |
    57 |
    58 |
    59 | User 60 |
    61 |
    62 |
    {user.email}
    63 |
    64 |
    65 |
    66 |
    67 | 73 |
    74 | -------------------------------------------------------------------------------- /src/components/Toast.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 |
    68 |
    69 |
    70 | {#if alertType == "info"} 71 | 77 | 82 | 83 | {:else if alertType == "warning"} 84 | 92 | 97 | 98 | {:else if alertType == "success"} 99 | 107 | 112 | 113 | {:else if alertType == "error"} 114 | 122 | 127 | 128 | {/if} 129 |
    130 |
    131 |
    {message}
    132 | 139 |
    140 |
    141 | 159 |
    160 |
    161 |
    162 | 163 | 171 | -------------------------------------------------------------------------------- /src/components/Toaster.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 |
    8 | {#each toasts as toast (toast.id)} 9 | 10 | {/each} 11 |
    -------------------------------------------------------------------------------- /src/components/UserSelect.svelte: -------------------------------------------------------------------------------- 1 | 62 | 63 |
    64 |
    65 | 72 |
    73 | {#if searching} 74 | 75 | {/if} 76 |
    77 |
    78 | 79 |
    80 | {#if searchResults.length > 0} 81 | {#each searchResults as userResult} 82 |
    toggleSelect(userResult)} 85 | class:selected={selectedUsers.some( 86 | (selected) => selected.id === userResult.id 87 | )} 88 | > 89 |
    90 |
    91 | User 92 |
    93 |
    94 |
    {userResult.email}
    95 |
    96 |
    97 |
    98 | {/each} 99 | {:else if searchCount > 0} 100 |
    No results
    101 | {/if} 102 |
    103 |
    104 | 105 | 110 | -------------------------------------------------------------------------------- /src/lib/GAS_API.js: -------------------------------------------------------------------------------- 1 | import polyfillScriptRun from "./polyfillScriptRun"; 2 | polyfillScriptRun(); 3 | 4 | /** 5 | * Generic function to handle API calls 6 | * @param {string} functionName 7 | * @param {any} [args=[]] - Optional arguments 8 | * @returns {Promise} 9 | */ 10 | const callAPI = async (functionName, args = []) => { 11 | console.log("calling api", functionName, args); 12 | return new Promise((resolve, reject) => { 13 | google.script.run 14 | .withSuccessHandler(resolve) 15 | .withFailureHandler(reject) 16 | [functionName](...(Array.isArray(args) ? args : [args])); 17 | }); 18 | }; 19 | 20 | export const GAS_API = { 21 | /** 22 | * @returns {Promise} the app configuration 23 | */ 24 | getAppConfiguration: () => callAPI("getAppConfiguration"), 25 | 26 | /** 27 | * @param {PutAppConfigArgs} args 28 | * @returns {Promise} the app configuration 29 | */ 30 | putAppConfiguration: (args) => callAPI("putAppConfiguration", args), 31 | 32 | /** 33 | * @param {GetUserArgs} [args] - Optional parameter containing user email 34 | * @returns {Promise} 35 | */ 36 | getUser: (args) => callAPI("getUser", args), 37 | 38 | /** 39 | * 40 | * @param {PutUserArgs} args 41 | * @returns {Promise} 42 | */ 43 | putUser: (args) => callAPI("putUser", args), 44 | 45 | /** 46 | * @param {GetViewConfigArgs} args 47 | * @returns {Promise} 48 | */ 49 | getViewConfiguration: (args) => callAPI("getViewConfiguration", args), 50 | 51 | /** 52 | * @param {GetViewDataArgs} args 53 | * @returns {Promise} 54 | */ 55 | getViewData: (args) => callAPI("getViewData", args), 56 | }; 57 | -------------------------------------------------------------------------------- /src/lib/fetchAppConfig.js: -------------------------------------------------------------------------------- 1 | import { isLoading, appConfiguration } from "../stores"; 2 | import { GAS_API } from "./GAS_API"; 3 | 4 | /** 5 | * Fetches the app configuration from the server. 6 | */ 7 | export async function fetchAppConfiguration() { 8 | isLoading.set(true); 9 | 10 | console.log("fetching app configuration..."); 11 | 12 | GAS_API.getAppConfiguration() 13 | .then((result) => { 14 | appConfiguration.set(result); 15 | }) 16 | .catch((err) => { 17 | console.error("Could not get app configuration:", err); 18 | }) 19 | .finally(() => { 20 | console.log("App configuration loaded."); 21 | isLoading.set(false); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/mocks/_API.ts: -------------------------------------------------------------------------------- 1 | import { getAppConfiguration } from "./api/getAppConfiguration"; 2 | import { getViewConfiguration } from "./api/getViewConfiguration"; 3 | import { getViewData } from "./api/getViewData"; 4 | import { getUser } from "./api/getUser"; 5 | import { putUser } from "./api/putUser"; 6 | import { putAppConfiguration } from "./api/putAppConfiguration"; 7 | 8 | type MockEndpoints = { 9 | // App Configuration 10 | getAppConfiguration: typeof getAppConfiguration; 11 | putAppConfiguration: typeof putAppConfiguration; 12 | 13 | // User 14 | getUser: typeof getUser; 15 | putUser: typeof putUser; 16 | 17 | // Views 18 | getViewConfiguration: typeof getViewConfiguration; 19 | getViewData: typeof getViewData; 20 | }; 21 | 22 | export default function getMockEndpoints(): MockEndpoints { 23 | return { 24 | // App Configuration 25 | getAppConfiguration, 26 | putAppConfiguration, 27 | 28 | // User 29 | getUser, 30 | putUser, 31 | 32 | // Views 33 | getViewConfiguration, 34 | getViewData, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/mocks/api/getAppConfiguration.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | import { appConfiguration } from "../data/appConfiguration"; 3 | import { AppConfigurationType, UserType, User } from "../../../types/schemas"; 4 | import { users } from "../data/user"; 5 | 6 | export async function getAppConfiguration(): Promise { 7 | sleep(); 8 | 9 | let appConfig = { 10 | ...appConfiguration, 11 | admins: getAdmins_(), 12 | }; 13 | 14 | let mockResponse = appConfig; 15 | 16 | console.log("mockResponse", mockResponse); 17 | 18 | return JSON.parse(JSON.stringify(mockResponse)); 19 | 20 | function getAdmins_(): UserType[] { 21 | // Return variable 22 | let adminUsers = []; 23 | 24 | // Loop through script properties object 25 | for (let a = 0; a < users.length; a++) { 26 | try { 27 | // Weed out properties that do not reprsent users 28 | let user = User.parse(users[a]); 29 | 30 | // If the user has an admin role, add them to our list 31 | if (user.roles.includes("admin")) { 32 | adminUsers.push(user); 33 | } 34 | } catch (error) { 35 | // Not a user, carry on 36 | console.error(error); 37 | } 38 | } 39 | 40 | return adminUsers; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/mocks/api/getUser.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | import { user, users } from "../data/user"; 3 | import { UserType } from "../../../types/schemas"; 4 | import { GetUserArgs } from "../../../server/api/getUser"; 5 | 6 | /** 7 | * @param {GetUserArgs} [optionalArgs] - Optional parameter containing user email 8 | * @returns {Promise} 9 | */ 10 | export async function getUser( 11 | { email }: GetUserArgs = { email: null } 12 | ): Promise { 13 | await sleep(); 14 | 15 | /** @type {User | undefined} */ 16 | let mockResponse = null; 17 | 18 | if (!email) { 19 | mockResponse = user; 20 | } else { 21 | let user = users.find((user) => user.email === email); 22 | if (user) { 23 | mockResponse = user; 24 | } 25 | } 26 | 27 | console.log("mockResponse", mockResponse); 28 | 29 | return JSON.parse(JSON.stringify(mockResponse)); 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/mocks/api/getViewConfiguration.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | import { viewConfigurations } from "../data/viewConfigurations"; 3 | import { ViewConfigurationType } from "../../../types/schemas"; 4 | import { GetViewConfigArgs } from "../../../server/api/getViewConfiguration"; 5 | 6 | export async function getViewConfiguration({ 7 | id, 8 | }: GetViewConfigArgs): Promise { 9 | console.log("getting view configuration for viewId:", id); 10 | await sleep(); 11 | 12 | /** @type {ViewConfiguration} */ 13 | let mockResponse = viewConfigurations.find((config) => config.id === id); 14 | 15 | return JSON.parse(JSON.stringify(mockResponse)); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/mocks/api/getViewData.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | import query from "../query"; 3 | import { getViewConfiguration } from "./getViewConfiguration"; 4 | import { ViewType } from "../../../types/schemas"; 5 | import { GetViewDataArgs } from "../../../server/api/getViewData"; 6 | 7 | /** 8 | * 9 | * @param {GetViewDataArgs} args 10 | * @returns {Promise} 11 | */ 12 | export async function getViewData({ id }: GetViewDataArgs): Promise { 13 | console.log("getting view data for viewId:", id); 14 | const viewConfiguration = await getViewConfiguration({ id }); 15 | 16 | if (!viewConfiguration) { 17 | throw new Error("View configuration not found"); 18 | } 19 | 20 | console.log("getting view data for config:", viewConfiguration); 21 | await sleep(2000); 22 | const resultSet = query(viewConfiguration); 23 | 24 | /** @type {View} */ 25 | let mockResponse = { 26 | source: "mocks", 27 | configuration: viewConfiguration, 28 | queryResult: { 29 | producedAt: new Date().toISOString(), 30 | records: resultSet, 31 | }, 32 | }; 33 | 34 | return JSON.parse(JSON.stringify(mockResponse)); 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/mocks/api/putAppConfiguration.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | 3 | import { PutAppConfigArgs } from "../../../server/api/putAppConfiguration"; 4 | import { AppConfiguration, AppConfigurationType } from "../../../types/schemas"; 5 | 6 | /** 7 | * @param {PutAppConfigArgs} args 8 | * @returns {Promise} 9 | */ 10 | export async function putAppConfiguration({ 11 | appConfiguration, 12 | }: PutAppConfigArgs): Promise { 13 | await sleep(); 14 | 15 | let validAppConfig = AppConfiguration.parse(appConfiguration); 16 | 17 | /** @type {AppConfiguration} */ 18 | let mockResponse = validAppConfig; 19 | 20 | return JSON.parse(JSON.stringify(mockResponse)); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/mocks/api/putUser.ts: -------------------------------------------------------------------------------- 1 | import sleep from "../sleep"; 2 | import { PutUserArgs } from "../../../server/api/putUser"; 3 | import { User, UserType } from "../../../types/schemas"; 4 | 5 | /** 6 | * **API Endpoint** | Updates the app configuration and returns it 7 | * @param {PutUserArgs} args 8 | * @returns {Promise} 9 | */ 10 | export async function putUser({ user }: PutUserArgs): Promise { 11 | await sleep(); 12 | 13 | const validUser = User.parse(user); 14 | 15 | /** @type {UserType} */ 16 | let mockResponse = validUser; 17 | 18 | console.log("mockResponse_user", mockResponse); 19 | 20 | return JSON.parse(JSON.stringify(mockResponse)); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/mocks/data/appConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { user } from "./user"; 2 | 3 | export let appConfiguration = { 4 | appName: "Mock App", 5 | deployingUserEmail: user.email, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/mocks/data/dataSourceConfigurations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @type {DatasourceConfiguration[]} 4 | */ 5 | export const dataSourceConfigurations = [ 6 | { 7 | spreadsheetId: "1XD1RVZsXw6DLQv_ksIdzuYdHPcsbpXhml94nMAz5_vA", 8 | gid: "0", 9 | storageType: "Google Sheets", 10 | schema: { 11 | fields: [ 12 | { 13 | label: "Full Name", 14 | name: "fullName", 15 | type: "string", 16 | }, 17 | { 18 | label: "Job", 19 | name: "job", 20 | type: "string", 21 | }, 22 | { 23 | label: "Favorite Color", 24 | name: "favoriteColor", 25 | type: "string", 26 | }, 27 | ], 28 | }, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/lib/mocks/data/mockData.ts: -------------------------------------------------------------------------------- 1 | export const mockData = { 2 | "1XD1RVZsXw6DLQv_ksIdzuYdHPcsbpXhml94nMAz5_vA": { 3 | records: [ 4 | { 5 | id: 1, 6 | fullName: "John", 7 | job: "Developer", 8 | favoriteColor: "Blue", 9 | stars: 5, 10 | created_at: 1690242893, 11 | }, 12 | { 13 | id: 2, 14 | fullName: "Jane", 15 | job: "Police", 16 | favoriteColor: "Green", 17 | stars: 4, 18 | created_at: 1690242893, 19 | }, 20 | { 21 | id: 3, 22 | fullName: "Jack", 23 | job: "Mayor", 24 | favoriteColor: "Red", 25 | stars: 2, 26 | created_at: 1690242893, 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/mocks/data/user.ts: -------------------------------------------------------------------------------- 1 | import { UserType } from "../../../types/schemas"; 2 | 3 | export const user: UserType = { 4 | email: "mock_mockerson@test.com", 5 | roles: ["superAdmin", "admin"], 6 | profile: { 7 | firstName: "Mock", 8 | lastName: "Mockerson", 9 | imageUrl: "../../../images/man2.jpeg", 10 | }, 11 | preferences: { 12 | theme: "dark", 13 | }, 14 | activity: [ 15 | { label: "User Created", value: "2021-01-01T00:00:00.000Z" }, 16 | { label: "User Updated", value: "2021-01-01T00:00:00.000Z" }, 17 | ], 18 | }; 19 | 20 | export const users: UserType[] = [ 21 | user, 22 | { 23 | email: "johndoe@test.com", 24 | roles: ["admin"], 25 | profile: { 26 | firstName: "John", 27 | lastName: "Doe", 28 | imageUrl: "../../../images/man1.jpeg", 29 | }, 30 | preferences: { 31 | theme: "light", 32 | }, 33 | activity: [ 34 | { label: "User Created", value: "2023-01-01T00:00:00.000Z" }, 35 | { label: "User Updated", value: "2023-03-12T00:00:00.000Z" }, 36 | ], 37 | }, 38 | { 39 | email: "janedoe@test.com", 40 | roles: [], 41 | profile: { 42 | firstName: "Jane", 43 | lastName: "Doe", 44 | imageUrl: "../../../images/woman1.jpeg", 45 | }, 46 | preferences: { 47 | theme: "light", 48 | }, 49 | activity: [ 50 | { label: "User Created", value: "2023-01-01T00:00:00.000Z" }, 51 | { label: "User Updated", value: "2023-03-12T00:00:00.000Z" }, 52 | ], 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /src/lib/mocks/data/viewConfigurations.ts: -------------------------------------------------------------------------------- 1 | import { ViewConfigurationType } from "../../../types/schemas"; 2 | 3 | /** 4 | * Mock data for view configurations 5 | * @type {ViewConfigurationType[]}} 6 | */ 7 | export const viewConfigurations: ViewConfigurationType[] = [ 8 | { 9 | id: "1", 10 | label: "First View", 11 | dataSource: { 12 | spreadsheetId: "1XD1RVZsXw6DLQv_ksIdzuYdHPcsbpXhml94nMAz5_vA", 13 | gid: "0", 14 | storageType: "Google Sheets", 15 | schema: { fields: [] }, 16 | }, 17 | pageSize: 10, 18 | fields: [ 19 | { 20 | label: "Full Name", 21 | name: "fullName", 22 | type: "string", 23 | width: 100, 24 | format: "text", 25 | filter: { 26 | type: "text", 27 | value: "", 28 | }, 29 | sort: { 30 | type: "text", 31 | value: "", 32 | }, 33 | }, 34 | { 35 | label: "Job", 36 | name: "job", 37 | type: "string", 38 | width: 100, 39 | format: "text", 40 | filter: { 41 | type: "text", 42 | value: "", 43 | }, 44 | sort: { 45 | type: "text", 46 | value: "", 47 | }, 48 | }, 49 | { 50 | label: "Favorite Color", 51 | name: "favoriteColor", 52 | type: "string", 53 | width: 100, 54 | format: "text", 55 | filter: { 56 | type: "text", 57 | value: "", 58 | }, 59 | sort: { 60 | type: "text", 61 | value: "", 62 | }, 63 | }, 64 | ], 65 | }, 66 | 67 | { 68 | id: "2", 69 | label: "Second View", 70 | dataSource: { 71 | spreadsheetId: "1XD1RVZsXw6DLQv_ksIdzuYdHPcsbpXhml94nMAz5_vA", 72 | gid: "0", 73 | storageType: "Google Sheets", 74 | schema: { fields: [] }, 75 | }, 76 | pageSize: 10, 77 | fields: [ 78 | { 79 | label: "Full Name", 80 | name: "fullName", 81 | type: "string", 82 | width: 100, 83 | format: "text", 84 | filter: { 85 | type: "text", 86 | value: "", 87 | }, 88 | sort: { 89 | type: "text", 90 | value: "", 91 | }, 92 | }, 93 | { 94 | label: "Favorite Color", 95 | name: "favoriteColor", 96 | type: "string", 97 | width: 100, 98 | format: "text", 99 | filter: { 100 | type: "text", 101 | value: "", 102 | }, 103 | sort: { 104 | type: "text", 105 | value: "", 106 | }, 107 | }, 108 | ], 109 | }, 110 | ]; 111 | -------------------------------------------------------------------------------- /src/lib/mocks/query.ts: -------------------------------------------------------------------------------- 1 | import { ViewConfigurationType } from "../../types/schemas"; 2 | import { mockData } from "./data/mockData"; 3 | 4 | /** 5 | * @param {ViewConfigurationType} viewConfiguration 6 | * @returns {Record[]} 7 | */ 8 | export default function query(viewConfiguration: ViewConfigurationType) { 9 | const { spreadsheetId, gid } = viewConfiguration.dataSource; 10 | const { fields } = viewConfiguration; 11 | const { records } = mockData[spreadsheetId]; 12 | const data = records.map((record) => { 13 | return fields.reduce((acc, column) => { 14 | acc[column.name] = record[column.name]; 15 | return acc; 16 | }, {}); 17 | }); 18 | return data; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/mocks/sleep.ts: -------------------------------------------------------------------------------- 1 | export default function sleep(milliseconds?: number): Promise { 2 | let ms = milliseconds || Math.floor(Math.random() * 2001) + 1000; 3 | return new Promise((resolve) => setTimeout(resolve, ms)); 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/polyfillScriptRun.js: -------------------------------------------------------------------------------- 1 | import getMockEndpoints from "./mocks/_API"; 2 | let polyfilled = false; 3 | 4 | export default async function polyfillScriptRun() { 5 | if (polyfilled) return; 6 | polyfilled = true; 7 | 8 | const _window = 9 | typeof window !== "undefined" 10 | ? window 11 | : typeof globalThis !== "undefined" 12 | ? globalThis 13 | : {}; 14 | 15 | const google = _window.google || {}; 16 | _window.google = google; 17 | 18 | if (!google.script || !google.script.run) { 19 | const mocks = getMockEndpoints(); 20 | google.script = google.script || {}; 21 | google.script.run = { 22 | withSuccessHandler: (resolve) => { 23 | return { 24 | withFailureHandler: (reject) => { 25 | const wrappedMocks = {}; 26 | for (const [key, value] of Object.entries(mocks)) { 27 | wrappedMocks[key] = async (...args) => { 28 | try { 29 | const result = await value(...args); 30 | resolve(result); 31 | } catch (error) { 32 | reject(error); 33 | } 34 | }; 35 | } 36 | return wrappedMocks; 37 | }, 38 | }; 39 | }, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/sanitizeEmail.ts: -------------------------------------------------------------------------------- 1 | export const sanitizeEmail = (email: string) => { 2 | const charsToRemove = ["@", "."]; 3 | const newString = email.replace( 4 | new RegExp(`[${charsToRemove.join("")}]`, "g"), 5 | "" 6 | ); 7 | return newString; 8 | }; 9 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import "./app.css"; 2 | import App from "./App.svelte"; 3 | 4 | document.getElementById("app-title").innerHTML = "Svelte App"; 5 | 6 | const app = new App({ 7 | target: document.getElementById("app"), 8 | }); 9 | 10 | export default app; 11 | -------------------------------------------------------------------------------- /src/routes/404.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
    6 |
    7 | 15 | 20 | 21 |
    22 |
    23 |

    24 | The page you've requested either does not exist or you do not have 25 | access to it. 26 |

    27 |
    28 |
    29 |

    30 | Go back to Home page 31 |

    32 |
    33 |
    34 | -------------------------------------------------------------------------------- /src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
    19 |
    20 |
    21 |

    22 | Welcome 23 |

    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | -------------------------------------------------------------------------------- /src/routes/Profile.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | {#if user && !loading} 43 | 54 |
    55 |
    56 |
    57 |
    60 | The user 61 |
    62 |
    63 |
    64 |

    65 | {user.profile.firstName} 66 | {user.profile.lastName} 67 |

    68 |
    69 |
    70 | {user.email} 71 |
    72 |
    73 | {#each user.roles as role} 74 | {role.toUpperCase()} 75 | {/each} 76 |
    77 |
    78 |
    79 |
    80 |
    81 |
    82 | 94 |
    95 |
    Total Likes
    96 |
    25.6K
    97 |
    21% more than last month
    98 |
    99 | 100 |
    101 |
    102 | 114 |
    115 |
    Page Views
    116 |
    2.6M
    117 |
    21% more than last month
    118 |
    119 | 120 |
    121 |
    86%
    122 |
    Tasks done
    123 |
    124 | 31 tasks remaining 125 |
    126 |
    127 |
    128 | 129 |
    130 | 131 | 132 | 133 | 134 | 137 | 140 | 141 | 142 | 143 | {#each user.activity as record} 144 | 145 | 146 | 147 | 148 | {/each} 149 | 150 |
    135 | Label 136 | 138 | Value 139 |
    {record.label}{record.value}
    151 |
    152 |
    153 |
    154 |
    155 | {/if} 156 | -------------------------------------------------------------------------------- /src/routes/Settings.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
    47 | {#if $appConfiguration} 48 | 49 | 52 |

    53 | Modify your app's settings. Remember to save! 54 |

    55 |
    56 | 59 | 66 |
    67 |
    68 | 69 | 70 | 76 |

    77 | Remember, admins have access to do some spooky stuff! 78 |

    79 |
    80 |
    81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | {#each $appConfiguration.admins as admin} 90 | 91 | 92 | 122 | 135 | 136 | {/each} 137 | 138 |
    Admin
    93 |
    96 |
    97 |
    100 | Avatar Tailwind CSS Component 105 |
    106 |
    107 |
    108 |
    109 | {admin.email} 110 |
    111 |
    112 | {#each admin.roles as role} 113 | {role} 117 | {/each} 118 |
    119 |
    120 |
    121 |
    123 | 130 | 134 |
    139 |
    140 |
    141 |
    142 | 143 | 144 | {/if} 145 |
    146 | -------------------------------------------------------------------------------- /src/routes/UserPreferences.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 |
    57 | 58 | 61 |

    62 | Information in this section is displayed on your profile page. 63 |

    64 |
    65 |
    66 | 69 | 76 |
    77 |
    78 | 81 | 88 |
    89 |
    90 |
    91 | 92 | 93 | 96 |

    97 | Modify your user preferences here. Remember to save! 98 |

    99 |
    100 |
    101 | 104 | 111 |
    112 |
    113 |
    114 | 115 | 116 |
    117 | -------------------------------------------------------------------------------- /src/routes/View.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 |
    44 | 45 | 46 | 47 | 48 | 52 | {/each} 53 | {/if} 54 | 55 | 56 | 57 | {#if viewData} 58 | {#each viewData?.queryResult.records as record} 59 | 60 | 61 | {#each viewData?.configuration.fields as field} 62 | 63 | {/each} 64 | 65 | {/each} 66 | {/if} 67 | 68 |
    49 | {#if viewData} 50 | {#each viewData.configuration.fields as field} 51 | {field.label}
    X{record[field.name]}
    69 |
    70 |
    71 | -------------------------------------------------------------------------------- /src/server/api/getAppConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { AppConfigurationType } from "../../types/schemas"; 2 | import { loadAppConfiguration_ } from "../lib/loadAppConfiguration_"; 3 | 4 | /** 5 | * **API Endpoint** | Returns the app configuration 6 | * @returns {AppConfiguration | null} 7 | */ 8 | export function getAppConfiguration(): AppConfigurationType | null { 9 | console.log("getting app configuration"); 10 | 11 | const appConfigurationObject = loadAppConfiguration_(); 12 | 13 | console.log(appConfigurationObject); 14 | 15 | // Do we want to filter the appConfig based on user? 16 | 17 | return appConfigurationObject; 18 | } 19 | -------------------------------------------------------------------------------- /src/server/api/getUser.ts: -------------------------------------------------------------------------------- 1 | import { UserType } from "../../types/schemas"; 2 | import { createUser_ } from "../lib/createUser_"; 3 | import { z } from "zod"; 4 | 5 | export type GetUserArgs = { 6 | email: string | null; 7 | }; 8 | 9 | /** 10 | * **API Endpoint** | Returns the accessing user object 11 | * @param {GetUserArgs} [optionalArgs] - Optional parameter containing user email. If no email is provided, the requesting user's email is used. 12 | * @returns {Promise} 13 | */ 14 | async function getUser( 15 | { email }: GetUserArgs = { email: null } 16 | ): Promise { 17 | let requestingUserEmail = Session.getActiveUser().getEmail(); 18 | // Report request 19 | console.log( 20 | "getUser called with args:", 21 | { email }, 22 | " | by: ", 23 | requestingUserEmail 24 | ); 25 | 26 | // Validate the arguments against the schema 27 | const GetUserArgsSchema = z.object({ 28 | email: z.string().nullable(), 29 | }); 30 | const validArgs = GetUserArgsSchema.parse({ email }); 31 | 32 | let EMAIL_FOR_RETRIEVAL = validArgs.email || requestingUserEmail; 33 | let isRequestForSelf = requestingUserEmail === EMAIL_FOR_RETRIEVAL; 34 | 35 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 36 | const scriptProperties = scriptPropertiesService.getProperties(); 37 | let userObjectString = scriptProperties[EMAIL_FOR_RETRIEVAL]; 38 | 39 | // If the requested user's object doesnt exist and the request is from 40 | // someone other than the requested user, return null. 41 | if (!userObjectString && !isRequestForSelf) { 42 | return null; 43 | } 44 | // Else if the the request user's object doesn't exist but it is a request 45 | // from the requested user, create the user object and return it. They 46 | // now exist in the system. 47 | else if (!userObjectString && isRequestForSelf) { 48 | let user = createUser_(EMAIL_FOR_RETRIEVAL); 49 | return user; 50 | } 51 | 52 | // Otherwise, the user object exists and we can return it. 53 | let user = JSON.parse(userObjectString); 54 | 55 | return user; 56 | } 57 | -------------------------------------------------------------------------------- /src/server/api/putAppConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { AppConfiguration, AppConfigurationType } from "../../types/schemas"; 2 | 3 | export type PutAppConfigArgs = { 4 | appConfiguration: AppConfigurationType; 5 | }; 6 | 7 | /** 8 | * **API Endpoint** | Updates the app configuration and returns it 9 | * @param {PutAppConfigArgs} args 10 | * @returns {AppConfiguration | null} 11 | */ 12 | function putAppConfiguration({ 13 | appConfiguration, 14 | }: PutAppConfigArgs): AppConfigurationType { 15 | console.log("putAppConfiguration() called with: ", appConfiguration); 16 | 17 | const validAppConfiguration = AppConfiguration.parse(appConfiguration); 18 | 19 | const propertyKey = "appConfiguration"; 20 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 21 | 22 | scriptPropertiesService.setProperty( 23 | propertyKey, 24 | JSON.stringify(appConfiguration) 25 | ); 26 | 27 | return validAppConfiguration; 28 | } 29 | -------------------------------------------------------------------------------- /src/server/api/putUser.ts: -------------------------------------------------------------------------------- 1 | import { User, UserType } from "../../types/schemas"; 2 | 3 | export type PutUserArgs = { 4 | user: UserType; 5 | }; 6 | 7 | /** 8 | * **API Endpoint** | Updates the app configuration and returns it 9 | * @param {PutUserArgs} args 10 | * @returns {UserType} 11 | */ 12 | export function putUser({ user }: PutUserArgs): UserType { 13 | const invokingUserEmail = Session.getActiveUser().getEmail(); 14 | 15 | console.log("putUser() called with: ", user, "by: ", invokingUserEmail); 16 | 17 | const validUser = User.parse(user); 18 | 19 | if ( 20 | validUser.email !== invokingUserEmail && 21 | validUser.email !== Session.getEffectiveUser().getEmail() 22 | ) { 23 | throw new Error( 24 | "A user resource can only be updated by themselves or the superAdmin." 25 | ); 26 | } 27 | 28 | // If the code reaches here, the user object is valid 29 | // and the invoking user is either the user or a superAdmin. 30 | const propertyKey = validUser.email; 31 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 32 | scriptPropertiesService.setProperty(propertyKey, JSON.stringify(validUser)); 33 | 34 | console.log("User successfully saved."); 35 | return validUser; 36 | } 37 | -------------------------------------------------------------------------------- /src/server/doGet.js: -------------------------------------------------------------------------------- 1 | function doGet(e) { 2 | // We shouldn't load the application if we aren't able to get the user's 3 | // identity. In this case, we return the noAuth.html page. 4 | let activeUserEmail = Session.getActiveUser().getEmail(); 5 | if (activeUserEmail === "") { 6 | return HtmlService.createTemplateFromFile("server/noAuth") 7 | .evaluate() 8 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); 9 | } 10 | 11 | // Otherwise, we check to see if this application has been initialized. 12 | // If not, we do so now 13 | let appConfig = loadAppConfiguration_(); 14 | if (!appConfig) { 15 | initializeApp_(); 16 | } 17 | 18 | // At this point we should 19 | 20 | return HtmlService.createHtmlOutputFromFile( 21 | "client/index.html" 22 | ).setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); 23 | } 24 | -------------------------------------------------------------------------------- /src/server/env.ts: -------------------------------------------------------------------------------- 1 | type EnvironmentDetails = { 2 | executeAs: "USER_DEPLOYING" | "USER_ACCESSING"; 3 | domain: { 4 | type: "Personal" | "Workspace"; 5 | name: string; 6 | }; 7 | }; 8 | 9 | export const ENV: EnvironmentDetails = { 10 | executeAs: "USER_DEPLOYING", // "USER_DEPLOYING" | "USER_ACCESSING" 11 | domain: { 12 | type: "Personal", 13 | name: "", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/server/lib/createUser_.ts: -------------------------------------------------------------------------------- 1 | import { UserType, User } from "../../types/schemas"; 2 | import { loadUserProfileImageUrl_ } from "./loadUserProfileImageUrl_"; 3 | 4 | export function createUser_(email: string, overrides = {}): UserType { 5 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 6 | const profileImgUrl = loadUserProfileImageUrl_(email); 7 | 8 | let userDefaults: UserType = { 9 | email, 10 | roles: [], 11 | preferences: { 12 | theme: "light", 13 | }, 14 | profile: { 15 | imageUrl: profileImgUrl, 16 | }, 17 | activity: [ 18 | { 19 | label: "User Created", 20 | value: new Date().toISOString(), 21 | }, 22 | ], 23 | }; 24 | 25 | let user = { 26 | ...userDefaults, 27 | ...overrides, 28 | }; 29 | 30 | let validUser = User.parse(user); // throws if invalid 31 | 32 | scriptPropertiesService.setProperty(email, JSON.stringify(validUser)); 33 | 34 | return user; 35 | } 36 | -------------------------------------------------------------------------------- /src/server/lib/getAdmins_.ts: -------------------------------------------------------------------------------- 1 | import { UserType, User } from "../../types/schemas"; 2 | 3 | export function getAdmins_(): UserType[] { 4 | // Return variable 5 | let adminUsers = []; 6 | 7 | // Load all of the script properties as an object 8 | const scriptProperties = 9 | PropertiesService.getScriptProperties().getProperties(); 10 | 11 | // Loop through script properties object 12 | for (const property in scriptProperties) { 13 | try { 14 | // Weed out properties that do not reprsent users 15 | let user = User.parse(JSON.parse(scriptProperties[property])); 16 | 17 | // If the user has an admin role, add them to our list 18 | if (user.roles.includes("admin") || user.roles.includes("superAdmin")) { 19 | adminUsers.push(user); 20 | } 21 | } catch (error) { 22 | // Not a user, carry on 23 | } 24 | } 25 | 26 | return adminUsers; 27 | } 28 | -------------------------------------------------------------------------------- /src/server/lib/getDeploymentDomain_.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @returns 4 | */ 5 | export function getDeploymentDomain_() { 6 | try { 7 | // @ts-ignore 8 | // We don't have People Advanced Service types 9 | let people = People.People.searchDirectoryPeople({ 10 | query: "", 11 | readMask: "photos", 12 | sources: "DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE", 13 | }); 14 | } catch (err) { 15 | if (err.toString().includes("Must be a G Suite domain user")) { 16 | return "Personal"; 17 | } else { 18 | console.error(err); 19 | } 20 | } 21 | 22 | return "Business"; 23 | } 24 | -------------------------------------------------------------------------------- /src/server/lib/initializeApp_.ts: -------------------------------------------------------------------------------- 1 | import { AppConfigurationType } from "../../types/schemas"; 2 | import { createUser_ } from "./createUser_"; 3 | import { loadAppConfiguration_ } from "./loadAppConfiguration_"; 4 | import { ENV } from "../env"; 5 | 6 | /** 7 | * Initialize the app 8 | */ 9 | export function initializeApp_(): Object { 10 | let superAdminEmail: string = ""; 11 | if (ENV.executeAs === "USER_DEPLOYING") { 12 | superAdminEmail = Session.getEffectiveUser().getEmail(); 13 | } else if (ENV.executeAs === "USER_ACCESSING") { 14 | superAdminEmail = DriveApp.getFileById(ScriptApp.getScriptId()) 15 | .getOwner() 16 | .getEmail(); 17 | } else { 18 | throw `App could not be initialized`; 19 | } 20 | 21 | const deployingUserEmail = Session.getEffectiveUser().getEmail(); 22 | 23 | createUser_(superAdminEmail, { roles: ["superAdmin"] }); 24 | 25 | let newAppConfig: AppConfigurationType = { 26 | appName: "My App", 27 | deployingUserEmail: deployingUserEmail, 28 | admins: [], 29 | }; 30 | 31 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 32 | scriptPropertiesService.setProperty( 33 | "appConfiguration", 34 | JSON.stringify(newAppConfig) 35 | ); 36 | 37 | let appConfig = loadAppConfiguration_(); 38 | 39 | return JSON.parse(JSON.stringify(appConfig)); 40 | } 41 | -------------------------------------------------------------------------------- /src/server/lib/loadAppConfiguration_.ts: -------------------------------------------------------------------------------- 1 | import { AppConfigurationType, AppConfiguration } from "../../types/schemas"; 2 | 3 | /** 4 | * Loads the app configuration from the script properties 5 | * @returns {AppConfiguration | null} 6 | */ 7 | const loadAppConfiguration_ = (): AppConfigurationType | null => { 8 | const scriptPropertiesService = PropertiesService.getScriptProperties(); 9 | const scriptProperties = scriptPropertiesService.getProperties(); 10 | const appConfigurationString = scriptProperties.appConfiguration || null; 11 | 12 | if (!appConfigurationString) { 13 | return null; 14 | } 15 | 16 | let appConfig: AppConfigurationType = { 17 | ...JSON.parse(appConfigurationString), 18 | admins: getAdmins_(), 19 | }; 20 | 21 | AppConfiguration.parse(appConfig); 22 | 23 | return appConfig; 24 | } 25 | 26 | export { 27 | loadAppConfiguration_, 28 | } -------------------------------------------------------------------------------- /src/server/lib/loadUserProfileImageUrl_.ts: -------------------------------------------------------------------------------- 1 | import { ENV } from "../env"; 2 | 3 | /** 4 | * 5 | * @param {string} email 6 | * @returns {string} A promise resolving to the user's profile image URL or a default img URL. 7 | */ 8 | export function loadUserProfileImageUrl_(email: string): string { 9 | let userPictureUrl; 10 | let defaultPictureUrl = 11 | "https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100"; 12 | try { 13 | // @ts-ignore 14 | // We don't have People Advanced Service types 15 | let people = People.People.searchDirectoryPeople({ 16 | query: email, 17 | readMask: "photos", 18 | sources: "DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE", 19 | }); 20 | 21 | userPictureUrl = people.people[0].photos[0].url; 22 | } catch (err) { 23 | console.log(err); 24 | } 25 | 26 | return userPictureUrl ?? defaultPictureUrl; 27 | } 28 | 29 | // function loadUserProfileImageUrl_(email: string | null = null): string { 30 | 31 | // let defaultPictureUrl = "https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100"; 32 | // let profileImageUrl = defaultPictureUrl 33 | 34 | // if (email === null) { 35 | 36 | // } 37 | 38 | // if (ENV.executeAs === "USER_ACCESSING") { 39 | // profileImageUrl = DriveApp.getRootFolder().getOwner().getPhotoUrl(); 40 | // } 41 | 42 | // return profileImageUrl 43 | // } 44 | -------------------------------------------------------------------------------- /src/server/lib/query.js: -------------------------------------------------------------------------------- 1 | function convertToObjects_(data) { 2 | const [headers, ...rows] = data; 3 | return rows.map((row) => { 4 | return headers.reduce((acc, header, index) => { 5 | acc[header] = row[index]; 6 | return acc; 7 | }, {}); 8 | }); 9 | } 10 | 11 | /** 12 | * @param {ViewConfiguration} viewConfiguration 13 | * @returns {QueryResult} 14 | */ 15 | function query(viewConfiguration) { 16 | const { spreadsheetId, gid } = viewConfiguration.dataSource; 17 | const { fields } = viewConfiguration; 18 | 19 | const ss = SpreadsheetApp.openById(spreadsheetId); 20 | const sheet = getSheetById(ss, parseInt(gid)); 21 | const dataRange = sheet.getDataRange(); 22 | const values = dataRange.getValues(); 23 | const records = convertToObjects_(values); 24 | 25 | const data = records.map((record) => { 26 | return fields.reduce((acc, column) => { 27 | acc[column.name] = record[column.name]; 28 | return acc; 29 | }, {}); 30 | }); 31 | 32 | return { 33 | producedAt: new Date().toISOString(), 34 | records: data, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/server/noAuth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

    9 | This application requires your Google account to be identifiable in order 10 | to work properly. The link you're accessing now has been deployed in such 11 | a way that prevents the app from accessing your account. Please contact 12 | 13 | for assistance. 14 |

    15 | 16 | 17 | -------------------------------------------------------------------------------- /src/stores.ts: -------------------------------------------------------------------------------- 1 | import { derived, writable } from "svelte/store"; 2 | import { UserType } from "./types/schemas"; 3 | import { AppConfigurationType } from "./types/schemas"; 4 | 5 | export const sessionUser = writable(null); 6 | export const userIsAdmin = derived(sessionUser, ($sessionUser) => { 7 | return ( 8 | $sessionUser?.roles.includes("admin") || 9 | $sessionUser?.roles.includes("superAdmin") 10 | ); 11 | }); 12 | 13 | export const isLoading = writable(false); 14 | 15 | export const appConfiguration = writable(null); 16 | -------------------------------------------------------------------------------- /src/types/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // const UserPreferences = z.record(z.any()); 4 | const UserPreferences = z.object({ 5 | theme: z.enum(["light", "dark"]).optional(), 6 | }); 7 | 8 | const UserProfile = z 9 | .object({ 10 | imageUrl: z.string(), 11 | }) 12 | .and(z.record(z.string())) 13 | .optional(); 14 | 15 | const UserActivity = z.object({ 16 | label: z.string(), 17 | value: z.string(), // You can add custom validation to ensure it's an ISO string 18 | }); 19 | 20 | const UserRoles = z.array(z.enum(["superAdmin", "admin"])); 21 | 22 | const User = z.object({ 23 | email: z.string().email(), 24 | roles: UserRoles, 25 | profile: UserProfile, 26 | preferences: UserPreferences, 27 | activity: z.array(UserActivity), 28 | }); 29 | 30 | const AppConfiguration = z.object({ 31 | appName: z.string(), 32 | deployingUserEmail: z.string(), 33 | admins: z.array(User), 34 | }); 35 | 36 | // You need to export in this format. See 37 | // https://stackoverflow.com/questions/48791868/use-typescript-with-google-apps-script 38 | // for more info. 39 | export { AppConfiguration, UserPreferences, UserProfile, UserActivity, User }; 40 | 41 | export type AppConfigurationType = z.infer; 42 | export type UserPreferencesType = z.infer; 43 | export type UserProfileType = z.infer; 44 | export type UserActivityType = z.infer; 45 | export type UserType = z.infer; 46 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{html,js,svelte,ts}'], 3 | theme: { 4 | extend: {} 5 | }, 6 | plugins: [require("daisyui")], 7 | 8 | daisyui: { 9 | themes: ["light", "dark"], 10 | }, 11 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { viteSingleFile } from 'vite-plugin-singlefile'; 3 | import { svelte } from '@sveltejs/vite-plugin-svelte' 4 | import copy from 'rollup-plugin-copy'; 5 | import del from 'rollup-plugin-delete'; 6 | import { resolve } from 'path'; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | svelte(), 12 | viteSingleFile(), 13 | 14 | // Delete the dist/ directory before each build 15 | del({ targets: 'dist/*' }), 16 | copy({ 17 | targets: [ 18 | { src: 'src/appsscript.json', dest: 'dist' }, 19 | ] 20 | }), 21 | copy({ 22 | targets: [ 23 | { src: 'src/types', dest: 'dist' }, 24 | { src: 'src/server', dest: 'dist' } 25 | ], 26 | flatten: false, 27 | }), 28 | ], 29 | build: { 30 | minify: true, 31 | outDir: resolve(__dirname, 'dist/client'), 32 | }, 33 | }) 34 | --------------------------------------------------------------------------------