Initial commit

This commit is contained in:
Ludvík Prokopec 2022-12-18 15:17:16 +01:00 committed by GitHub
commit 9c24e7943a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 7926 additions and 0 deletions

14
.editorconfig Normal file
View File

@ -0,0 +1,14 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
charset = utf-8
trim_trailing_whitespace = false
quote_type = single
[*.{yml, md}]
indent_style = space
indent_size = 2

8
.env Normal file
View File

@ -0,0 +1,8 @@
# project endpoint (required) [example: http://localhost/v1]
VITE_APPWRITE_ENDPOINT=http://localhost/v1
# project id (required) [example: 638871b363904655d784]
VITE_APPWRITE_PROJECT_ID=638871b363904655d784
# project hostname (required) [example: http://localhost:5173]
VITE_HOSTNAME=http://localhost:5173

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# appwrite
appwrite

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": false,
"trailingComma": "all",
"proseWrap": "preserve",
"printWidth": 180,
"useTabs": true,
"singleQuote": true
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Ludvík Prokopec
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

229
README.md Normal file
View File

@ -0,0 +1,229 @@
# Svelte + Appwrite = 🚀
## Appwrite svelte template
Blazing fast development with done backend and fully-prepared frontend.
CMS ready!
## Appwrite installation
[Appwrite installation](https://appwrite.io/docs/installation)
## Frontend included
* tailwind
* scss
* css reset
* typescript
* routing
* ready routes
* oauth
* files upload, download
* folder structure
* common components
* service worker
* path aliases
* database realtime subscribers
* database paginate, infinity scroll
* i18n
* cms
* cms forms components
* vite
* prettier
* editorconfig
* icons: [Bootstrap icons](https://icons.getbootstrap.com/)
## Database subscribers
```svelte
<script>
import { Collection } from '$lib/database'
import { Query } from 'appwrite'
const collection = new Collection('[database-id]', '[collection-id]')
const [subscriber, loading] = collection.createSubscriber([Query.limit(5) /*, ...queries */])
// listen changes (update, delete) in database and automatically rerender on change
// current data = [{ name: 'John', lastName: 'Doe' }, ...]
const insertSubscriber = collection.createObserver()
// listen changes (create) in database and automatically rerender on change
const [paginator, paginatorInitalLoading] = collection.createPaginate(10, [/* ...queries */])
// paginate the collection of documents with limit and automatically rerender on change
// paginator.next() makes the next request for items, paginator store automatically rerender on next load
const [scrollData, scrollDispatch] = collection.createInfinityScrollDispatcher(10, [/* ...queries */], { /* intersection observer options */ })
// load next data after scroll to anchor (scrollDispatch) element
</script>
<div>
{#if $loading}
<p>Loading data from database...</p>
{:else}
{#each [...$subscriber, ...$insertSubscriber] as item}
<p>{item.name}</p>
{/each}
{/if}
</div>
<!-- scroll dispatcher example -->
<div>
{#each $scrollData as item}
<p>{item.name}</p>
{/each}
<div use:scrollDispatch on:fetch={(e) => console.log(e) /* on every fetch from scroll dispatcher do some action */} />
</div>
```
## Files subscribers
```svelte
<script>
import { Bucket } from '$lib/storage'
import { Query } from 'appwrite'
const bucket = new Bucket('[bucket-id]')
const [files, loading] = bucket.createSubscriber([Query.limit(5) /*, ...queries */])
// listen changes (update, delete) in files and automatically rerender on change
const insertSubscriber = bucket.createObserver()
// listen changes (create) in files and automatically rerender on change
const [upload, dispatch] = storage.createUploadDispatcher(/* many files ? true : false, default = false */)
const [content, loading] = storage.getFileContent('6391f7c70ede82115575')
// get file content and automatically rerender on file update
</script>
<div>
<input type="file" use:upload />
<button on:click={() => dispatch().then(uploadedFile => console.log(uploadedFile))}>Upload</button>
</div>
```
## Routing
Routes can be added in `__routes.svelte` file. Every route is fetched lazyly.
```svelte
<script lang="ts">
import Router from '$lib/router/Router.svelte'
import Layout from '$src/__layout.svelte'
import Loading from '$src/__loading.svelte'
import Error from '$src/__error.svelte'
</script>
<Router
layout={Layout}
loading={Loading}
error={Error}
routes={[
{
path: '/',
component: () => import('$routes/index.svelte'),
},
{
path: '/oauth',
component: () => import('$routes/oauth/index.svelte'),
},
{
path: '/oauth/failure',
component: () => import('$routes/oauth/failure.svelte'),
},
{
path: '/oauth/success',
component: () => import('$routes/oauth/success.svelte'),
},
]}
/>
```
### Routes structure
`__layout.svelte` the default layout for every page
`__error.svelte` the error page (404 error)
`__loading.svelte` the default loading component
`__routes.svelte` the file includes all routes in application
## Social auth
```svelte
<script>
import { account, url } from '$lib/stores/appwrite'
</script>
<div>
<button on:click={() => account.createOAuth2Session('github', url.oauth.success, url.oauth.failure)}>
Github
</button>
</div>
```
## i18n
Locale file `src/locales/en.json`
```json
{
"page": {
"home": {
"title": "Appwrite svelte rocket start 🚀"
}
}
}
```
```svelte
<script>
import { _, locale, locales } from 'svelte-i18n'
</script>
<div>
<h1>{$_('page.home.title')}</h1>
<div>
<p>Change language:</p>
<select bind:value={$locale}>
{#each $locales as locale}
<option value={locale}>{locale}</option>
{/each}
</select>
</div>
</div>
```
## path aliases
`$lib` = `src/lib`
`$root` = `/`
`$src` = `src`
`$cms` = `src/cms`
`$routes` = `src/routes`
## commands
```bash
npm run dev
```
```bash
npm run build
```
```bash
npm run preview
```
```bash
npm run appwrite
```

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte + Appwrite</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
jsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

6418
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "appwrite-svelte-rocket-start",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"appwrite": "docker compose -f ./appwrite/docker-compose.yml up"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.1.0",
"autoprefixer": "^10.4.13",
"flowbite-svelte": "^0.28.4",
"postcss": "^8.4.19",
"sass": "^1.56.1",
"svelte": "^3.52.0",
"svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.3",
"vite": "^3.2.3"
},
"dependencies": {
"@bytemd/plugin-gfm": "^1.17.4",
"appwrite": "^10.1.0",
"bytemd": "^1.17.4",
"svelte-i18n": "^3.6.0",
"svelte-routing": "^1.6.0"
}
}

6
postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

12
public/nginx.conf Normal file
View File

@ -0,0 +1,12 @@
server {
server_name cms.example.com;
location / {
proxy_pass http://127.0.0.1:xxxx;
}
}
server {
server_name *.example.com;
location / {
proxy_pass http://127.0.0.1:xxxx;
}
}

4
public/serviceworker.js Normal file
View File

@ -0,0 +1,4 @@
self.addEventListener("install", (e) => { })
self.addEventListener('fetch', (e) => { })

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

29
src/App.svelte Normal file
View File

@ -0,0 +1,29 @@
<script lang="ts">
import './main.scss'
import { i18n, isLoading } from './locales/i18n'
import { onMount } from 'svelte'
import { Router } from '$lib/router'
import Routes from './__routes.svelte'
let isMounted = false
onMount(() => {
/** init i18n */
i18n()
/** register service worker */
if ('serviceWorker' in window.navigator) {
window.navigator.serviceWorker.register('/serviceworker.js', {
scope: '/',
})
}
isMounted = true
})
</script>
<Router>
{#if !$isLoading && isMounted}
<Routes />
{/if}
</Router>

1
src/__error.svelte Normal file
View File

@ -0,0 +1 @@
<h1>Error 404</h1>

3
src/__layout.svelte Normal file
View File

@ -0,0 +1,3 @@
<main class="container">
<slot />
</main>

1
src/__loading.svelte Normal file
View File

@ -0,0 +1 @@
<p>Loading...</p>

31
src/__routes.svelte Normal file
View File

@ -0,0 +1,31 @@
<script lang="ts">
import Router from '$lib/router/Router.svelte'
import Layout from '$src/__layout.svelte'
import Loading from '$src/__loading.svelte'
import Error from '$src/__error.svelte'
</script>
<Router
layout={Layout}
loading={Loading}
error={Error}
routes={[
{
path: '/',
component: () => import('$routes/index.svelte'),
},
{
path: '/oauth',
component: () => import('$routes/oauth/index.svelte'),
},
{
path: '/oauth/failure',
component: () => import('$routes/oauth/failure.svelte'),
},
{
path: '/oauth/success',
component: () => import('$routes/oauth/success.svelte'),
},
]}
/>

3
src/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/cms/components.ts Normal file
View File

@ -0,0 +1,10 @@
import FileDrop from './components/FileDrop.svelte'
import Input from './components/Input.svelte'
import Sortable from './components/Sortable.svelte'
import BlockQuote from './components/BlockQuote.svelte'
import MarkdownEditor from './components/MarkdownEditor.svelte'
import MarkdownRenderer from './components/MarkdownRenderer.svelte'
import { Radio, Checkbox, Fileupload as FileUpload, Range, Select, Textarea, Toggle, Hr as HorizontalRule, Button, ButtonGroup } from 'flowbite-svelte'
export { FileDrop, Input, Radio, Checkbox, FileUpload, Range, Select, Textarea, Toggle, Sortable, BlockQuote, HorizontalRule, MarkdownEditor, MarkdownRenderer, Button, ButtonGroup }

View File

@ -0,0 +1,9 @@
<script>
import { Blockquote, P } from 'flowbite-svelte'
</script>
<Blockquote border bg class="p-4 my-4">
<P height="relaxed">
<slot />
</P>
</Blockquote>

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { Dropzone } from 'flowbite-svelte'
export let rules = []
</script>
<Dropzone on:blur on:change on:click on:focus on:mouseenter on:mouseleave on:mouseover>
<svg aria-hidden="true" class="mb-3 w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"><span class="font-semibold">Click to upload</span> or drag and drop</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{rules.join(', ')}</p>
</Dropzone>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { Input, Label, Helper } from 'flowbite-svelte'
import id from './idGenerator'
const elementId = id('input-')
export let label = ''
export let value = ''
export let error: string = null
export let type:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'file'
| 'hidden'
| 'image'
| 'month'
| 'number'
| 'password'
| 'reset'
| 'submit'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week'
| 'search' = 'text'
</script>
<div class="mb-6">
<Label for={elementId} color={error ? 'red' : null} class="mb-2">{label}</Label>
<Input bind:value {type} color={error ? 'red' : null} id={elementId} {...$$props}>
<slot />
</Input>
{#if error}
<Helper class="mt-2" color="red">
<span class="font-medium">Error!</span>
{error}
</Helper>
{/if}
</div>

View File

@ -0,0 +1,5 @@
<div class="h-full w-full top-0 left-0 flex justify-center items-center">
<div class="animate-spin inline-block w-6 h-6 border-[3px] border-current border-t-transparent text-current rounded-full" role="status" aria-label="loading">
<span class="sr-only">Loading...</span>
</div>
</div>

View File

@ -0,0 +1,15 @@
<script>
import 'bytemd/dist/index.css'
import { Editor } from 'bytemd'
import gfm from '@bytemd/plugin-gfm'
const plugins = [gfm()]
export let value = ''
</script>
<div>
<template>
<Editor {value} {plugins} on:change={(e) => (value = e.detail.value)} />
</template>
</div>

View File

@ -0,0 +1,15 @@
<script>
import 'bytemd/dist/index.css'
import { Viewer } from 'bytemd'
import gfm from '@bytemd/plugin-gfm'
const plugins = [gfm()]
export let value = ''
</script>
<div>
<template>
<Viewer {value} {plugins} />
</template>
</div>

View File

@ -0,0 +1,60 @@
<script>
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
export let list
export let key = 'id'
export let element = 'div'
export let active = true
const dispatch = createEventDispatcher()
let source = null
let isOver = false
const start = (e, id) => {
e.dataTransfer.clearData()
e.dataTransfer.setData('text/plain', id)
}
const over = (target) => isOver !== target.id && (isOver = target.id)
const leave = () => isOver === source?.id && (isOver = false)
const reorder = (source, target) => {
if (!active || !source || !target) return
if (source.id === target.id) return
const { order: sourceIndex } = source
const { order: targetIndex } = target
isOver = false
list[sourceIndex] = [list[targetIndex], (list[targetIndex] = list[sourceIndex])][0]
list = list
dispatch('reorder', { source, target })
source = null
}
</script>
{#each list as item, order (item[key])}
<svelte:element
this={element}
draggable={active}
on:dragstart={(e) => (source = { order, id: item[key] }) && start(e, source.id)}
on:dragover|preventDefault={() => over({ order, id: item[key] })}
on:dragleave={leave}
on:dragenter|preventDefault={() => null}
on:drop|preventDefault={() => reorder(source, { order, id: item[key] })}
animate:flip={{ duration: source !== null ? 300 : 0 }}
class:over={item[key] === isOver}
>
<slot {item} {order} index={order} />
</svelte:element>
{/each}
<style>
.over {
border-color: rgba(48, 12, 200, 0.2);
}
</style>

View File

@ -0,0 +1,6 @@
const gen = (function* () {
let index = -1
while (true) yield index++
})()
export default (pre: string = '') => `${pre}${gen.next().value}`

22
src/lib/appwrite.ts Normal file
View File

@ -0,0 +1,22 @@
import { Client, Account, Databases, Storage, Teams, Functions, Locale, Avatars } from 'appwrite'
const client = new Client()
const account = new Account(client)
const databases = new Databases(client)
const storage = new Storage(client)
const teams = new Teams(client)
const functions = new Functions(client)
const locale = new Locale(client)
const avatars = new Avatars(client)
const url = {
oauth: {
success: `${import.meta.env.VITE_HOSTNAME}/oauth/success`,
failure: `${import.meta.env.VITE_HOSTNAME}/oauth/failure`
}
}
client.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT).setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID)
export default client
export { client, account, url, databases, storage, teams, functions, locale, avatars }

31
src/lib/auth.ts Normal file
View File

@ -0,0 +1,31 @@
import type { Models, RealtimeResponseEvent } from 'appwrite'
import { writable } from 'svelte/store'
import { account, client } from './appwrite'
const userStore = writable<Models.Account<Models.Preferences>>(null)
const loadingStore = writable(true)
client.subscribe('account', (response: RealtimeResponseEvent<any>) => {
if (response.events.includes('users.*.sessions.*.delete')) {
return userStore.set(null)
}
if (response.events.includes('users.*.sessions.*.update')) {
return userStore.set(response.payload)
}
})
account.get().then(data => {
userStore.set(data)
loadingStore.set(false)
})
const user = {
subscribe: userStore.subscribe,
logout: () => account.deleteSession('current'),
account: account
}
const isLoading = { subscribe: loadingStore.subscribe }
export { account, user, isLoading }

View File

@ -0,0 +1,15 @@
<div>
<slot />
</div>
<style lang="scss">
div {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
color: rgba(0, 0, 0, 0.7);
box-shadow: #d1d5db 0px -4px 0px inset, rgba(0, 0, 0, 0.4) 0px 1px 1px;
padding: 0.25rem 0.5rem;
}
</style>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { link } from '$lib/router'
export let href: string | null = null
let className = ''
export { className as class }
const isValidHttpUrl = (string) => {
let url: URL
try {
url = new URL(string)
} catch (_) {
return false
}
return url.protocol === 'http:' || url.protocol === 'https:'
}
</script>
{#if href}
{#if isValidHttpUrl(href)}
<a {href} class={className}>
<slot />
</a>
{:else}
<a {href} class={className} use:link>
<slot />
</a>
{/if}
{:else}
<button class={className} on:click>
<slot />
</button>
{/if}

View File

@ -0,0 +1,5 @@
<div class="h-full w-full top-0 left-0 flex justify-center items-center">
<div class="animate-spin inline-block w-6 h-6 border-[3px] border-current border-t-transparent text-current rounded-full" role="status" aria-label="loading">
<span class="sr-only">Loading...</span>
</div>
</div>

View File

@ -0,0 +1,14 @@
<script lang="ts">
export let columns = 3
export let gap = 4
</script>
<div class="masonry-grid columns-{columns} gap-x-{gap}">
<slot />
</div>
<style lang="scss">
:global(div.masonry-grid *) {
break-inside: avoid;
}
</style>

View File

@ -0,0 +1,9 @@
<div class="flex">
<aside class="sidebar__sidebar w-[30%]">
<slot name="aside" />
</aside>
<main class="overflow-auto flex-1">
<slot />
</main>
</div>

View File

@ -0,0 +1,23 @@
<script lang="ts">
export let space = '3rem'
</script>
<div class="layout-stack" style="--space: {space};">
<slot />
</div>
<style lang="scss">
:global(.layout-stack) {
display: flex;
flex-direction: column;
justify-content: flex-start;
& > * {
margin-block: 0;
}
& > * + * {
margin-block-start: var(--space, 1.5rem);
}
}
</style>

178
src/lib/database.ts Normal file
View File

@ -0,0 +1,178 @@
import { writable } from 'svelte/store'
import { databases, client } from './appwrite'
import { Models, Query, RealtimeResponseEvent } from 'appwrite'
import { ID } from 'appwrite'
import type { Writable } from 'svelte/store'
class Collection {
constructor(protected databaseId: string, protected collectionId: string) { }
createDocument(data: { [key: string]: any } = {}, permissions: string[] = null) {
return databases.createDocument(this.databaseId, this.collectionId, ID.unique(), data, permissions)
}
updateDocument(documentId: string, data: { [key: string]: any } = {}, permissions: string[] = null) {
if (permissions.length === 0 && Object.keys(data).length === 0) return
return databases.updateDocument(this.databaseId, this.collectionId, documentId, data, permissions)
}
deleteDocument(documentId: string) {
return databases.deleteDocument(this.databaseId, this.collectionId, documentId)
}
createObserver() {
const dataStore = writable<Models.Document[]>([])
client.subscribe(`databases.${this.databaseId}.collections.${this.collectionId}.documents`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`databases.${this.databaseId}.collections.${this.collectionId}.documents.*.create`)) {
dataStore.update(current => {
current.push(response.payload)
return current
})
this.subscribeCollectionUpdate(response.payload, dataStore)
}
})
return { subscribe: dataStore.subscribe }
}
createSubscriber(queries: string[] = []) {
const loadingStore = writable(true)
const dataStore = writable<Models.Document[]>([])
databases.listDocuments(this.databaseId, this.collectionId, queries).then(data => {
data.documents.forEach((document) => this.subscribeCollectionUpdate(document, dataStore))
dataStore.set(data.documents)
loadingStore.set(false)
})
return [{ subscribe: dataStore.subscribe }, { subscribe: loadingStore.subscribe }] as const
}
createPaginate(limit: number, queries: string[] = []) {
const dataStore = writable<Models.Document[]>([])
const loadingStore = writable(true)
let offset = 0
const store = {
subscribe: dataStore.subscribe,
async next() {
const data = await databases.listDocuments(this.databaseId, this.collectionId, [...queries, Query.limit(limit), Query.offset(offset)])
data.documents.forEach((document) => this.subscribeCollectionUpdate(document, dataStore))
dataStore.update(current => [...current, ...data.documents])
offset += limit
}
}
store.next().then(() => loadingStore.set(false))
return [store, { subscribe: loadingStore.subscribe }] as const
}
createInfinityScrollDispatcher(limit: number, queries: string[] = [], observerOptions: IntersectionObserverInit = {}) {
const dataStore = writable<Models.Document[]>([])
let lastId: string = null
databases.listDocuments(this.databaseId, this.collectionId, [...queries, Query.limit(limit)]).then(firstData => {
dataStore.set(firstData.documents)
firstData.documents.forEach((document) => this.subscribeCollectionUpdate(document, dataStore))
lastId = firstData.documents[firstData.documents.length - 1].$id
})
const observer = new IntersectionObserver((entries, me) => {
if (lastId === null) return
entries.forEach(entry => {
if (!entry.isIntersecting) return
databases.listDocuments(this.databaseId, this.collectionId, [...queries, Query.limit(limit), Query.cursorAfter(lastId)]).then((data) => {
dataStore.update(current => {
current.push(...data.documents)
lastId = current[current.length - 1].$id
return current
})
data.documents.forEach((document) => this.subscribeCollectionUpdate(document, dataStore))
entry.target.dispatchEvent(new CustomEvent('fetch', entry.target as CustomEventInit<HTMLElement>))
})
})
}, observerOptions)
const directive = (node: HTMLElement) => {
observer.observe(node)
return {
destroy() {
observer.disconnect()
}
}
}
return [{ subscribe: dataStore.subscribe }, directive] as const
}
protected subscribeCollectionUpdate(document: Models.Document, store: Writable<Models.Document[]>) {
client.subscribe(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${document.$id}`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${document.$id}.delete`)) {
store.update(current => {
current.splice(current.indexOf(document), 1)
return current
})
return
}
if (response.events.includes(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${document.$id}.update`)) {
store.update(current => {
current[current.indexOf(document)] = response.payload
return current
})
return
}
})
}
}
class Document {
constructor(protected databaseId: string, protected collectionId: string, protected documentId: string) { }
createSubscriber() {
const dataStore = writable<Models.Document>(null)
const loadingStore = writable(true)
databases.getDocument(this.databaseId, this.collectionId, this.documentId).then(data => {
dataStore.set(data)
loadingStore.set(false)
})
client.subscribe(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${this.documentId}`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${this.documentId}.update`)) {
dataStore.set(response.payload)
return
}
if (response.events.includes(`databases.${this.databaseId}.collections.${this.collectionId}.documents.${this.documentId}.delete`)) {
dataStore.set(null)
return
}
})
return [{ subscribe: dataStore.subscribe }, { subscribe: loadingStore.subscribe }] as const
}
delete() {
return databases.deleteDocument(this.databaseId, this.collectionId, this.documentId)
}
update(data: { [key: string]: any } = {}, permissions: string[] = []) {
if (permissions.length === 0 && Object.keys(data).length === 0) return
return databases.updateDocument(this.databaseId, this.collectionId, this.documentId, data, permissions)
}
}
export { Collection, Document }

8
src/lib/router.ts Normal file
View File

@ -0,0 +1,8 @@
import { Route, Router, link, links, Link } from 'svelte-routing'
import { navigate, back, forward } from './router/navigate'
import Redirect from "./router/Redirect.svelte"
import ProtectedRoute from "./router/ProtectedRoute.svelte"
import LazyRoute from "./router/LazyRoute.svelte"
import defineRoutes from "./router/routes"
export { Route, Router, Link, link, links, navigate, back, forward, Redirect, ProtectedRoute, LazyRoute, defineRoutes }

View File

@ -0,0 +1,14 @@
<script lang="ts">
import type { ComponentType, SvelteComponentTyped } from 'svelte'
export let path: string
export let component: () => Promise<any>
export let loading: ComponentType<SvelteComponentTyped<any>> | null = null
import { Route } from '$lib/router'
import LazyRouteGuard from './LazyRouteGuard.svelte'
</script>
<Route {path} let:location let:params>
<LazyRouteGuard {location} {params} {component} {loading} />
</Route>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { onMount, SvelteComponent } from 'svelte'
import type { ComponentType, SvelteComponentTyped } from 'svelte'
import type { RouteLocation, RouteParams } from 'svelte-routing/types/Route'
export let component: (() => Promise<any>) | ComponentType<SvelteComponentTyped<any>>
export let loading: ComponentType<SvelteComponentTyped<any>> | null = null
export let location: RouteLocation
export let params: RouteParams
export let before: (() => any) | null = null
let loadedComponent = null
const __before = async () => (before ? await before() : null)
onMount(() => {
__before()
if (component instanceof SvelteComponent) {
loadedComponent = component
return
}
;(component as () => Promise<any>)().then((module) => {
loadedComponent = module.default
})
})
</script>
{#if loadedComponent}
<svelte:component this={loadedComponent} {params} {location} />
{:else if loading}
<svelte:component this={loading} {params} {location} />
{/if}

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { Route } from 'svelte-routing'
import ProtectedRouteGuard from './ProtectedRouteGuard.svelte'
export let path: string
export let fallback = '/'
export let allow = true
export let component: any = null
</script>
<Route {path} let:params let:location>
<ProtectedRouteGuard {allow} {fallback} {location}>
{#if component !== null}
<svelte:component this={component} {location} {params} />
{:else}
<slot {params} {location} />
{/if}
</ProtectedRouteGuard>
</Route>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import type { RouteLocation } from 'svelte-routing/types/Route'
import Redirect from './Redirect.svelte'
export let fallback: string
export let allow: boolean
export let location: RouteLocation
</script>
{#if allow}
<slot />
{:else}
<Redirect to={fallback} replace state={{ from: location.pathname }} />
{/if}

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte'
import { navigate } from 'svelte-routing'
export let to: string
export let state: any = null
export let replace = false
export let then: (to: string) => any = () => null
onMount(() => {
navigate(to, { replace, state })
then(to)
})
</script>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { ComponentType, SvelteComponentTyped } from 'svelte'
import { Route, Router } from 'svelte-routing'
import LazyRouteGuard from './LazyRouteGuard.svelte'
interface Route {
path: string
component: ComponentType<SvelteComponentTyped<any>> | (() => Promise<any>)
before?: () => any
layout?: ComponentType<SvelteComponentTyped<any>>
loading?: ComponentType<SvelteComponentTyped<any>>
}
export let layout: ComponentType<SvelteComponentTyped<any>> | null = null
export let loading: ComponentType<SvelteComponentTyped<any>> | null = null
export let error: ComponentType<SvelteComponentTyped<any>> | null = null
export let routes: Route[] = []
</script>
<Router>
{#each routes as route}
<Route path={route.path} let:location let:params>
{#if route?.layout || layout}
<svelte:component this={route?.layout ?? layout}>
<LazyRouteGuard {location} {params} before={route?.before} loading={route?.loading ?? loading} component={route.component} />
</svelte:component>
{:else}
<LazyRouteGuard {location} {params} before={route?.before} loading={route?.loading ?? loading} component={route.component} />
{/if}
</Route>
{#if error}
<Route path="/*" component={error} />
{/if}
{/each}
</Router>

View File

@ -0,0 +1,9 @@
import { navigate as nav } from 'svelte-routing'
export const navigate = (to: string | number, options?: { replace?: boolean, state?: { [k in string | number]: unknown } }) => {
if (typeof to === 'string') return nav(to, options)
window.history.go(to)
}
export const back = () => window.history.back()
export const forward = () => window.history.forward()

29
src/lib/router/routes.ts Normal file
View File

@ -0,0 +1,29 @@
import { ComponentType, SvelteComponentTyped } from 'svelte'
export interface Route {
component: () => Promise<any>,
path: string,
layout?: ComponentType<SvelteComponentTyped<any>>,
loading?: ComponentType<SvelteComponentTyped<any>>,
before?: () => any,
}
interface RouteDefinition {
component: () => Promise<any>,
path: string,
layout: ComponentType<SvelteComponentTyped<any>> | null,
loading: ComponentType<SvelteComponentTyped<any>> | null,
before: () => any | null,
}
interface RouteConfig {
routes: Route[],
layout?: ComponentType<SvelteComponentTyped<any>> | null,
loading?: ComponentType<SvelteComponentTyped<any>> | null,
}
const defineRoutes = (config: RouteConfig): RouteDefinition[] => {
return config.routes.map(route => ({ ...route, layout: route?.layout ?? config?.layout ?? null, loading: route?.loading ?? config?.loading ?? null, before: route?.before ?? null }))
}
export default defineRoutes

186
src/lib/storage.ts Normal file
View File

@ -0,0 +1,186 @@
import { storage, client } from './appwrite'
import { ID, Models, RealtimeResponseEvent } from 'appwrite'
import { Writable, writable } from 'svelte/store'
class Bucket {
constructor(protected bucketId: string) { }
createFile(file, permissions: string[] = []) {
return storage.createFile(this.bucketId, ID.unique(), file, permissions)
}
deleteFile(file: string | Models.File) {
return storage.deleteFile(this.bucketId, typeof file === 'string' ? file : file.$id)
}
updateFile(file: string | Models.File, permissions: string[] = []) {
return storage.updateFile(this.bucketId, typeof file === 'string' ? file : file.$id, permissions)
}
getFilePreview(file: string | Models.File) {
return storage.getFilePreview(this.bucketId, typeof file === 'string' ? file : file.$id)
}
getFileDownload(file: string | Models.File) {
return storage.getFileDownload(this.bucketId, typeof file === 'string' ? file : file.$id)
}
getFileView(file: string | Models.File) {
return storage.getFileView(this.bucketId, typeof file === 'string' ? file : file.$id)
}
getFileContent(file: string | Models.File) {
const fileContent = writable('')
const loading = writable(true)
const { href } = storage.getFileView(this.bucketId, typeof file === 'string' ? file : file.$id)
this.subscribeFileUpdateCallback(file, () => fetch(href).then(res => res.ok ? res.text() : null).then(res => {
fileContent.set(res ?? '')
loading.set(false)
}))
fetch(href).then(res => res.ok ? res.text() : null).then(res => {
fileContent.set(res ?? '')
loading.set(false)
})
return [{ subscribe: fileContent.subscribe }, { subscribe: loading.subscribe }] as const
}
createUploadDispatcher(acceptManyFiles = false) {
let files = []
const eventUploadDirective = (node: HTMLInputElement) => {
const eventListener = (e) => files = acceptManyFiles ? Array.from(e.target.files) : [e.target.files[0]]
node.addEventListener('change', eventListener)
acceptManyFiles && node.setAttribute('multiple', 'multiple')
return {
destroy() {
node.removeEventListener('change', eventListener)
}
}
}
const dispatchUpload = (permissions: string[] = []) => {
return Promise.all(files.map(file => this.createFile(file, permissions)))
}
return [eventUploadDirective, dispatchUpload] as const
}
createSubsciber(queries: string[] = [], search = '') {
const filesStore = writable<Models.File[]>([])
const loadingStore = writable(true)
storage.listFiles(this.bucketId, queries, search).then(({ files }) => {
files.forEach(file => this.subscribeFileUpdate(file, filesStore))
filesStore.set(files)
loadingStore.set(false)
})
return [{ subscribe: filesStore.subscribe }, { subscribe: loadingStore.subscribe }] as const
}
createObserver() {
const dataStore = writable<Models.File[]>([])
client.subscribe(`buckets.${this.bucketId}.files`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`buckets.${this.bucketId}.files.*.create`)) {
dataStore.update(current => {
current.push(response.payload)
return current
})
this.subscribeFileUpdate(response.payload, dataStore)
}
})
return { subscribe: dataStore.subscribe }
}
protected subscribeFileUpdate(file: Models.File, filesStore: Writable<Models.File[]>) {
this.subscribeFileUpdateCallback(file, ({ event }) => {
if (event === 'update') return filesStore.update(current => {
current[current.indexOf(file)] = file
return current
})
filesStore.update(current => {
current.splice(current.indexOf(file), 1)
return current
})
})
}
protected subscribeFileUpdateCallback(file: string | Models.File, callback: ({ fileId, event }: { fileId: string, event: 'update' | 'delete' }) => any) {
client.subscribe(`buckets.${this.bucketId}.files.${typeof file === 'string' ? file : file.$id}`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`buckets.${this.bucketId}.files.${typeof file === 'string' ? file : file.$id}.update`)) {
return callback({ fileId: typeof file === 'string' ? file : file.$id, event: 'update' })
}
if (response.events.includes(`buckets.${this.bucketId}.files.${typeof file === 'string' ? file : file.$id}.delete`)) {
return callback({ fileId: typeof file === 'string' ? file : file.$id, event: 'delete' })
}
})
}
}
class File {
constructor(protected bucketId: string, protected fileId: string) { }
createSubscriber() {
const fileStore = writable<Models.File>(null)
const loadingStore = writable(true)
storage.getFile(this.bucketId, this.fileId).then((result) => {
fileStore.set(result)
loadingStore.set(false)
})
client.subscribe(`buckets.${this.bucketId}.files.${this.fileId}`, (response: RealtimeResponseEvent<any>) => {
if (response.events.includes(`buckets.${this.bucketId}.files.${this.fileId}.update`)) {
fileStore.set(response.payload)
return
}
if (response.events.includes(`buckets.${this.bucketId}.files.${this.fileId}.delete`)) {
fileStore.set(null)
return
}
})
return [{ subscribe: fileStore.subscribe }, { subscribe: loadingStore.subscribe }] as const
}
delete() {
return storage.deleteFile(this.bucketId, this.fileId)
}
update(permissions: string[] = []) {
return storage.updateFile(this.bucketId, this.fileId, permissions)
}
getPreview() {
return storage.getFilePreview(this.bucketId, this.fileId)
}
getDownload() {
return storage.getFileDownload(this.bucketId, this.fileId)
}
getView() {
return storage.getFileView(this.bucketId, this.fileId)
}
async getContent() {
const { href } = await storage.getFileView(this.bucketId, this.fileId)
return await fetch(href)
}
}
export { Bucket, File }

7
src/locales/en.json Normal file
View File

@ -0,0 +1,7 @@
{
"page": {
"home": {
"title": "Appwrite svelte rocket start 🚀"
}
}
}

11
src/locales/i18n.ts Normal file
View File

@ -0,0 +1,11 @@
import { register, init, getLocaleFromNavigator, isLoading, locale, locales } from 'svelte-i18n'
import registers from './languages'
Object.entries(registers).forEach(([key, file]) => register(key, file))
export const i18n = () => init({
fallbackLocale: 'en',
initialLocale: getLocaleFromNavigator(),
})
export { isLoading, locale, locales }

3
src/locales/languages.ts Normal file
View File

@ -0,0 +1,3 @@
export default {
'en': () => import('./en.json')
}

8
src/main.js Normal file
View File

@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app')
})
export default app

16
src/main.scss Normal file
View File

@ -0,0 +1,16 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
width: 100%;
tab-size: 4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}

19
src/routes/index.svelte Normal file
View File

@ -0,0 +1,19 @@
<script lang="ts">
import Link from '$lib/components/Link.svelte'
import { _ } from 'svelte-i18n'
</script>
<div class="flex justify-center mt-20">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
<span class="block text-indigo-600">{$_('page.home.title')}</span>
<p>
<Link class="underline" href="https://appwrite.io/">Appwrite</Link>
</p>
<p>
<Link class="underline" href="/oauth">OAuth</Link>
</p>
<p>
<Link class="underline" href="https://github.com/lewis-wow/appwrite-svelte-rocket-start">Repository</Link>
</p>
</h1>
</div>

View File

@ -0,0 +1 @@
<h1>OAuth failed</h1>

View File

@ -0,0 +1,7 @@
<script>
import { account, url } from '$lib/appwrite'
</script>
<div>
<button on:click={() => account.createOAuth2Session('github', url.oauth.success, url.oauth.failure)}> Github </button>
</div>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import { navigate } from '$lib/router'
import { onMount } from 'svelte'
onMount(() => {
const currentTitle = document.title
document.title = 'Authorizing...'
navigate('/', { replace: true })
return () => (document.title = currentTitle)
})
</script>
<p>Authorizing...</p>

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

15
tailwind.config.cjs Normal file
View File

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{html,js,svelte,ts}',
'./cms/**/*.{html,js,svelte,ts}',
'./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}',
],
theme: {
extend: {}
},
plugins: [
require('flowbite/plugin')
],
darkMode: 'class'
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true,
"paths": {
"$lib/*": [
"./src/lib/*"
],
"$root/*": [
"./*"
],
"$src/*": [
"./src/*"
],
"$cms/*": [
"./cms/*"
],
"$routes/*": [
"./src/routes/*"
],
},
}
}

25
vite.config.js Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import preprocess from "svelte-preprocess"
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'$lib': path.resolve(__dirname, 'src', 'lib'),
'$root': path.resolve(__dirname),
'$src': path.resolve(__dirname, 'src'),
'$cms': path.resolve(__dirname, 'src', 'cms'),
'$routes': path.resolve(__dirname, 'src', 'routes')
}
},
plugins: [
svelte({
preprocess: preprocess({
scss: true,
postcss: true
})
})
]
})