feat: mvp

It's completed, sort of.
Already know that it will be rewritten on 2.0.0.
But it is usable and just misses some features.

I'm tired.
This commit is contained in:
Guz013
2023-05-23 16:37:34 -03:00
commit 7c86e18f2b
59 changed files with 8006 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
PUBLIC_TWITTER_API_ID=""
PRIVATE_TWITTER_API_SECRET=""
PRIVATE_TWITTER_AUTH_TOKEN=""
PUBLIC_TWITTER_OAUTH2_API_ID=""
PRIVATE_TWITTER_OAUTH2_API_SECRET=""
PUBLIC_MASTODON_API_ID=""
PRIVATE_MASTODON_API_SECRET=""
PRIVATE_INSTANCES_API_TOKEN=""
PUBLIC_DEPLOYED_VERSION="1.0.0"

13
.eslintignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

24
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,24 @@
module.exports = {
extends: ['@vospel', '@unocss'],
env: {
browser: true,
node: true,
es2017: true,
},
overrides: [
{
files: ['*.cjs'],
env: {
node: true,
commonjs: true,
},
},
],
rules: {
'@typescript-eslint/indent': ['error', 'tab'],
'semi': ['error', 'always'],
'indent': 'off',
'no-tabs': ['error', { allowIndentationTabs: true }],
'jsonc/indent': ['error', 'tab'],
},
};

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
.DS_Store
node_modules
/.vercel
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
src-tauri/target
dist

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest

17
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.quickSuggestions": {
"strings": true
},
"cSpell.words": [
"ofetch",
"Twooth",
"Unsubscriber",
"Writables"
],
"dotenv.enableAutocloaking": false,
}

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "twooth",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"lint:fix": "eslint --fix ."
},
"devDependencies": {
"@iconify-json/solar": "^1.1.1",
"@iconify-json/svg-spinners": "^1.1.1",
"@iconify/utils": "^2.1.5",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-vercel": "^3.0.0",
"@sveltejs/kit": "^1.5.0",
"@sveltekit-addons/document": "^1.0.0",
"@types/crypto-js": "^4.1.1",
"@types/twitter-text": "^3.1.5",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"@unocss/eslint-config": "^0.51.12",
"@unocss/extractor-svelte": "^0.51.12",
"@vite-pwa/sveltekit": "^0.2.1",
"@vospel/eslint-config": "^1.6.0",
"eslint": "^8.28.0",
"eslint-plugin-svelte": "^2.26.0",
"ofetch": "^1.0.1",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"tslib": "^2.4.1",
"twitter-text": "^3.1.0",
"typescript": "^5.0.0",
"ufo": "^1.1.2",
"unocss": "^0.51.12",
"unocss-preset-radix": "^2.4.0",
"uuid": "^9.0.0",
"vite": "^4.3.0"
},
"type": "module"
}

5778
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

12
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

15
src/app.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-[#161616] text-[#161616] transition-all delay-500 dark-mode">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="currentColor"
d="M21.26 13.99c-.275 1.41-2.457 2.955-4.963 3.254c-1.306.156-2.593.3-3.965.236c-2.244-.103-4.014-.535-4.014-.535c0 .218.014.426.04.62c.292 2.215 2.196 2.347 4 2.41c1.82.061 3.44-.45 3.44-.45l.075 1.646s-1.273.684-3.54.81c-1.252.068-2.805-.032-4.613-.51c-3.923-1.039-4.598-5.22-4.701-9.464c-.032-1.26-.012-2.447-.012-3.44c0-4.34 2.843-5.611 2.843-5.611C7.283 2.298 9.742 2.021 12.3 2h.062c2.557.02 5.018.298 6.451.956c0 0 2.843 1.272 2.843 5.61c0 0 .036 3.201-.396 5.424Zm-2.957-5.087c0-1.074-.274-1.927-.823-2.558c-.566-.631-1.307-.955-2.228-.955c-1.065 0-1.872.41-2.405 1.228l-.518.87l-.519-.87C11.277 5.8 10.47 5.39 9.406 5.39c-.921 0-1.663.324-2.229.955c-.549.631-.822 1.484-.822 2.558v5.253h2.081V9.057c0-1.075.452-1.62 1.357-1.62c1 0 1.501.647 1.501 1.927v2.79h2.07v-2.79c0-1.28.5-1.927 1.5-1.927c.905 0 1.358.545 1.358 1.62v5.1h2.08V8.902Z" />
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 256 256">
<g fill="currentColor">
<path
d="m240 72l-32 32c-4.26 66.84-60.08 120-128 120c-32 0-40-12-40-12s32-12 48-36c0 0-55.15-32-47.22-120c0 0 39.66 40 87.22 48V88c0-22 18-40.27 40-40a40.74 40.74 0 0 1 36.67 24Z"
opacity=".2" />
<path
d="M247.39 68.94A8 8 0 0 0 240 64h-30.43a48.65 48.65 0 0 0-41.47-24a46.87 46.87 0 0 0-33.74 13.7A47.87 47.87 0 0 0 120 88v6.09C79.74 83.47 46.81 50.72 46.46 50.37a8 8 0 0 0-13.65 4.92c-4.3 47.79 9.57 79.77 22 98.18a110.92 110.92 0 0 0 21.89 24.2c-15.27 17.53-39.25 26.74-39.5 26.84a8 8 0 0 0-3.85 11.93c.74 1.12 3.75 5.05 11.08 8.72C53.51 229.7 65.48 232 80 232c70.68 0 129.72-54.42 135.76-124.44l29.9-29.9a8 8 0 0 0 1.73-8.72Zm-45 29.41a8 8 0 0 0-2.32 5.14C196 166.58 143.28 216 80 216c-10.56 0-18-1.4-23.22-3.08c11.52-6.25 27.56-17 37.88-32.48a8 8 0 0 0-2.61-11.34c-.13-.08-12.86-7.74-24.48-25.29C54.54 124.11 48 100.38 48 73.09c16 13 45.25 33.18 78.69 38.8A8 8 0 0 0 136 104V88a32 32 0 0 1 9.6-22.92A30.89 30.89 0 0 1 167.9 56c12.66.16 24.49 7.88 29.44 19.21a8 8 0 0 0 7.33 4.79h16Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

4
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare namespace svelteHTML {
import type { AttributifyAttributes } from '@unocss/preset-attributify';
type HTMLAttributes = AttributifyAttributes;
}

22
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Handle } from '@sveltejs/kit';
/// @ts-expect-error
import { handle as documentHandler } from '@sveltekit-addons/document/hooks';
import { dev } from '$app/environment';
import { sequence } from '@sveltejs/kit/hooks';
const handle: Handle = async ({ event, resolve }) => {
if (event.url.origin !== 'https://twooth.vercel.app' && !dev) {
return new Response(JSON.stringify({
status: 200,
message: 'Access Denied',
}), {
status: 200,
statusText: 'Access Denied',
headers: { 'Content-Type': 'application/json' },
});
}
return await resolve(event);
};
export default sequence(handle, documentHandler);

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let icon: string;
export let type: string | undefined = undefined;
const dispatch = createEventDispatcher();
</script>
<button
class="h-10 w-full cursor-pointer appearance-none gap-2 b-0 rounded-xl font-bold"
un-outline="hover:(hue8 2 solid)"
un-p="x-3 y-2 hover:(x-3 y-2)"
un-text="hue12 hover:(gray12)"
un-flex="~ items-center"
un-bg="hue8 hover:gray2"
on:click={() => dispatch('click')}
{type}
>
<span class={`my-auto text-xl inline-block ${icon}`} />
<span class="my-auto"><slot /></span>
</button>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
export let id: string;
export let value = false;
export let disabled = false;
export let required = false;
let className = '';
export { className as class };
if (disabled) value = false;
</script>
<span
class={`inline-flex items-center gap-1 transition-colors cursor-pointer ${className} ${
!disabled
? value
? 'text-hue9 focus-within:(text-hue10) hover:(text-hue10)'
: 'text-hue7 focus-within:(text-hue8) hover:(text-hue8)'
: 'text-gray6 line-through'
}`}
>
<input
class={`accent-hue11 w-5 h-5 appearance-none text-xl cursor-pointer ${
value
? [
"i-solar:check-square-bold-duotone",
"focus:(i-solar:check-square-bold) hover:(i-solar:check-square-bold)",
].join(' ')
: [
"i-solar:close-square-line-duotone",
"focus:(i-solar:close-square-bold-duotone) hover:(i-solar:close-square-bold-duotone)",
].join(' ')
}`}
bind:value
type="checkbox"
name={id}
id={`${id}-check`}
on:click|preventDefault={() => (value = !value)}
{disabled}
{required}
/>
<label
for={`${id}-check`}
class="inline-flex cursor-pointer items-center gap-1"
>
<slot />
</label>
</span>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import Users from './Users.svelte';
import ButtonIcon from '$lib/components/ButtonIcon.svelte';
import {
lastMastodonPost,
lastTwitterPost,
currentUser,
} from '$lib/state/users';
</script>
<menu class="m-0 h-full flex flex-col items-end justify-between">
<Users />
<section class="mb-6 w-full flex flex-col gap-2">
{#if !$currentUser.twitter.token}
<a
href={`/twitter/login?noise=${Math.random()}`}
un-w="full"
un-decoration="none"
>
<ButtonIcon icon="i-custom:twitter">Login Twitter</ButtonIcon>
</a>
{:else if $lastTwitterPost}
<a
href={`https://twitter.com/${$currentUser.twitter.username}/status/${$lastTwitterPost}`}
target="_blank"
un-w="full"
un-decoration="none"
>
<ButtonIcon icon="i-custom:twitter">
<span un-inline-flex un-items="center" un-gap="1">
Previous post
<span
class="i-solar:square-top-down-bold-duotone"
un-p="r-2"
un-inline-block
/>
</span>
</ButtonIcon>
</a>
{/if}
{#if !$currentUser.mastodon.token}
<a href="/mastodon/login" un-w="full" un-decoration="none">
<ButtonIcon icon="i-custom:mastodon">Login Mastodon</ButtonIcon>
</a>
{:else if $lastMastodonPost}
{#key $lastMastodonPost}
<a
href={`${$currentUser.mastodon.instance?.url}/@${$currentUser.mastodon.username}/${$lastMastodonPost}`}
target="_blank"
un-w="full"
un-decoration="none"
>
<ButtonIcon icon="i-custom:mastodon">
<span un-inline-flex un-items="center" un-gap="1">
Previous post
<span
class="i-solar:square-top-down-bold-duotone"
un-p="r-2"
un-inline-block
/>
</span>
</ButtonIcon>
</a>
{/key}
{/if}
</section>
</menu>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import {
type UserProfile,
currentUser,
currentUserId,
} from '$lib/state/users';
import { derived } from 'svelte/store';
import UserDelete from './UserDelete.svelte';
export let user: UserProfile;
const selected = derived(
currentUser,
($currentUser) => $currentUser.id === user.id,
);
// https://svelte.dev/tutorial/animate
const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
fallback(node, _params) {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: translate(0px, ${t - 1}rem);
opacity: ${t};
`,
};
},
});
</script>
<li
class={`hue-${user.config.color} ${
$selected
? 'bg-hue3 outline-hue9 outline-2 outline-solid'
: 'bg-gray2 hover:(outline-hue9 outline-2 outline-solid)'
}`}
un-flex
un-p="2"
un-rounded="xl"
un-items-center
un-justify-between
un-relative
in:receive={{ key: user.id }}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<label
for="account"
empty
un-flex
un-items-center
un-cursor="pointer"
on:click|once={() => {
currentUserId.set(user.id);
console.log(user.id);
}}
>
<button type="radio" id="account" name="account" un-hidden />
<div un-block un-rounded="full">
{#if user.twitter.profilePicture}
<img
src={`${user.twitter.profilePicture}`}
un-block
un-rounded="full"
un-w="9"
un-h="9"
alt="Mastodon profile"
id="profile-picture"
/>
{:else if user.mastodon.profilePicture}
<img
src={`${user.mastodon.profilePicture}`}
un-block
un-rounded="full"
un-w="9"
un-h="9"
alt="Mastodon profile"
id="profile-picture"
/>
{:else}
<picture
un-block
un-rounded="full"
un-w="9"
un-h="9"
empty
id="profile-picture"
/>
{/if}
</div>
<div un-text="sm" un-m="l-1">
{#if user.mastodon.token || user.twitter.token}
{@const single = !(user.mastodon.token && user.twitter.token)}
{#if user.twitter.token}
<p
un-m="0"
un-truncate
un-w={$selected ? 40 : 30}
un-text={`gray11 ${single ? 'base' : ''}`}
un-flex
un-items-center
>
<span class="i-custom:twitter" un-p="r-2" un-inline-block />
@{ user.twitter.username }
<a
href={`https://twitter.com/${user.twitter.username}`}
target="_blank"
>
<span
class="i-solar:square-top-down-broken"
un-p="r-2"
un-text="gray11"
un-inline-block
/>
</a>
</p>
{/if}
{#if user.mastodon.token}
<p
un-m="0"
un-truncate
un-w={$selected ? 40 : 30}
un-text={`gray11 ${single ? 'base' : ''}`}
un-flex
un-items-center
>
<span class="i-custom:mastodon" un-p="r-2" un-inline-block />
@{ user.mastodon.username }
<a
href={`https://${user.mastodon.instance?.domain}/@${user.mastodon.username}`}
target="_blank"
>
<span
class="i-solar:square-top-down-broken"
un-p="r-2"
un-text="gray11"
un-inline-block
/>
</a>
</p>
{/if}
{:else}
<p
un-m="0"
un-truncate
un-w={$selected ? 40 : 30}
un-text="gray11 base"
un-flex
un-items-center
>
<span
class="i-solar:users-group-rounded-bold-duotone"
un-p="r-2"
un-text="xl"
un-inline-block
/>@{ user.config.color }
</p>
{/if}
</div>
</label>
{#if !$selected}
<UserDelete {user} />
{/if}
</li>
<style>
#profile-picture[empty] {
background-image: linear-gradient(
to bottom right,
var(--un-preset-radix-hue5),
var(--un-preset-radix-hue9)
);
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { type UserProfile, deleteUser } from '$lib/state/users';
export let user: UserProfile;
let isConfirming = false;
</script>
{#if !isConfirming}
<div>
<button
un-text="gray7 hover:(tomato9) 2xl"
un-cursor="pointer"
un-w="10"
un-h="10"
un-flex
un-items-center
un-bg="transparent"
un-border="0"
on:click={() => (isConfirming = true)}
>
<span
class="i-solar:trash-bin-minimalistic-bold-duotone"
un-inline-block
/>
</button>
</div>
{:else}
<div
un-bg="[#00000080]"
un-outline="3 gray5 solid"
un-absolute
un-w="full"
un-h="full"
un-z="10"
un-flex
un-rounded="xl"
un-items-center
un-top="0"
un-left="0"
un-justify-between
class="backdrop-blur-sm"
>
<p un-m="0 l-3">Delete profile?</p>
<div un-flex un-items-center un-gap="1" un-m="r-3">
<button
un-text="gray9 hover:(tomato11) 2xl"
un-cursor="pointer"
un-p="0"
un-flex
un-bg="transparent"
un-border="0"
un-inline-block
on:click={() => deleteUser(user.id)}
>
<span class="i-solar:check-circle-line-duotone" un-inline-block />
</button>
<button
un-text="gray9 hover:(gray12) 2xl"
un-cursor="pointer"
un-p="0"
un-flex
un-bg="transparent"
un-border="0"
un-inline-block
on:click={() => (isConfirming = false)}
>
<span class="i-solar:close-circle-line-duotone" un-inline-block />
</button>
</div>
</div>
<span un-w="10" un-h="10" />
{/if}

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import {
createNewUser,
currentUserId,
usersMap,
currentUser,
} from '$lib/state/users';
import User from './User.svelte';
</script>
<fieldset un-relative un-p="0" un-border="none 0" un-w="full">
<legend un-text="sm gray5" un-justify="end">
Profiles
</legend>
<ul
un-flex un-flex-col un-gap="2" un-w="full"
un-left="0" un-p="0">
{#key $currentUserId}
<User user={$currentUser} />
{/key}
{#each Array.from($usersMap) as [userId, user]}
{#if userId !== $currentUserId}
{#key userId}
<User {user} />
{/key}
{/if}
{/each}
{#if Array.from($usersMap).length < 4}
<!-- content here -->
<li
un-block
un-bg="transparent"
un-border="2 dashed gray3"
un-p="2"
un-rounded="xl"
>
<label
for="add-account"
empty
un-flex
un-items-center
un-cursor="pointer"
>
<button
id="add-account"
name="add-account"
un-hidden
on:click={() => createNewUser()}
/>
<div
un-block
un-rounded="full"
un-w="9"
un-h="9"
un-flex
un-justify-center
un-items-center
un-bg="gray3"
>
<span un-text="xl" class="i-solar:user-plus-rounded-line-duotone" />
</div>
<div>
<p un-m="0 l-3" un-truncate un-w="40">Add profile</p>
</div>
</label>
</li>
{/if}
</ul>
</fieldset>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
export let value = 0;
export let max = 100;
export let label: string | undefined = undefined;
export let width = 'w-10';
export let height = 'h-10';
export let color = 'text-hue10';
let className = '';
export { className as class };
</script>
<span class={`${className} ${color}`}>
<div class={`${height} flex gap-2 items-center justify-center`}>
{#if label}
<p>{ label }</p>
{/if}
<span class={`${width} ${height} block bg-hue2 rounded-[50%]`}>
<span
id="progress"
class="block h-full w-full"
style={`--deg: ${(value / max) * 360}deg;`}
/>
</span>
</div>
</span>
<style scoped>
span#progress {
background: conic-gradient(currentColor, transparent var(--deg));
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { writable } from 'svelte/store';
import twitter from 'twitter-text';
export let value = '';
export let characters = 0;
export let overLimit = false;
export let characterLimit: number;
export let required = false;
export let cols = 50;
export let rows = 10;
export let name: string;
export let placeholder: string;
$: characters = twitter.parseTweet(value).weightedLength;
$: overLimit = characters > characterLimit ? true : false;
</script>
<div class="rounded-xl bg-gray2 p-4 pr-5 focus-within:(outline-5 outline-hue9 outline-solid)"
>
<textarea
class={[
"w-full m-0 block bg-transparent resize-none border-0 text-gray12 text-xl outline-none",
"placeholder-text-2xl placeholder-gray5 placeholder-font-bold",
].join(' ')}
id={`${name}-textarea`}
maxlength={characterLimit + characterLimit / 2}
{...{ placeholder, name, cols, rows }}
bind:value
{required}
/>
</div>

33
src/lib/env/colors.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
import type { RadixColors } from 'unocss-preset-radix';
export type Color = RadixColors;
export const userColors: Color[] = [
'tomato',
'crimson',
'pink',
'plum',
'purple',
'violet',
'indigo',
'blue',
'cyan',
'teal',
'green',
'grass',
'orange',
'brown',
];
export const brightColors: Color[] = [
'sky', 'mint', 'lime', 'yellow', 'amber', 'red',
];
const colors: Color[] = [
'gray', 'sand', 'slate', 'mauve',
...userColors, ...brightColors,
];
export default colors;
export function getRandomColor(): Color {
return userColors[Math.floor(Math.random() * userColors.length)];
}

4
src/lib/env/globals.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export const BASE_URL = import.meta.env.DEV ? 'http://localhost:3000' : 'https://twooth.vercel.app';
export const TWITTER_REDIRECT = `${BASE_URL}/twitter/oauth`;
export const MASTODON_REDIRECT = `${BASE_URL}/mastodon/oauth`;
export const MASTODON_SCOPES = 'read:accounts read:statuses write:media write:statuses';

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { derived, type Writable } from 'svelte/store';
import { currentUser, type UserProfile } from '$lib/state/users';
import { updateAppState, version } from '$lib/state/config';
import { persistent } from '$lib/state/store';
const config = derived<Writable<UserProfile>, UserProfile['config']>(
currentUser,
(user) => user.config,
);
updateAppState();
</script>
<svelte:body twooth-version={$version} class={`hue-${$config.color} ${$config.darkMode ? 'dark-theme' : 'light-theme'} bg-gray1 `} />
<style>
:global(*) {
@apply transition-all duration-50;
}
</style>

14
src/lib/qFetch.ts Normal file
View File

@@ -0,0 +1,14 @@
import { ofetch, type FetchOptions } from 'ofetch';
import { withQuery, type QueryObject, normalizeURL } from 'ufo';
export default async function qFetch<T>(
request: string,
info?: {
query?: QueryObject
options?: FetchOptions<'json'>
},
): Promise<T> {
return ofetch<T>(withQuery(
normalizeURL(request), { ...info?.query },
), info?.options);
}

23
src/lib/state/config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { get } from 'svelte/store';
import { persistent } from './store';
import { PUBLIC_DEPLOYED_VERSION } from '$env/static/public';
export interface Config {
customClient?: {
twitter?: {
id: string
secret: string
}
}
}
export const config = persistent<Config>('twooth.config', {});
export const version = persistent<string>('twooth.version', '1.0.0');
export function updateAppState(): void {
if (get(version) !== PUBLIC_DEPLOYED_VERSION) {
console.log('Updated!');
}
return;
}

38
src/lib/state/store.ts Normal file
View File

@@ -0,0 +1,38 @@
import { derived, get, writable, type Readable, type Writable, type Unsubscriber } from 'svelte/store';
import { browser } from '$app/environment';
export function persistent<T>(
key: string,
startValue?: T,
encode: (v: T) => string = JSON.stringify,
decode: (v: string) => T = JSON.parse,
): Writable<T> {
const store = writable<T>(startValue);
if (!browser) return store;
const value = localStorage.getItem(key);
if (value) {
store.set(decode(value));
}
store.subscribe(storeValue => {
if (startValue) {
localStorage.setItem(key, encode(storeValue));
} else {
localStorage.removeItem(key);
}
});
window.addEventListener('storage', () => {
const localValue = localStorage.getItem(key);
if (!localValue) return;
if (decode(localValue) !== get(store)) store.set(decode(localValue));
});
return store;
}

138
src/lib/state/users.ts Normal file
View File

@@ -0,0 +1,138 @@
import {
derived, get,
writable,
type Subscriber,
type Unsubscriber,
type Writable,
} from 'svelte/store';
import { v4 as uuid } from 'uuid';
import type { Instance } from '$lib/types/mastodon';
import { persistent } from '$lib/state/store';
import { type Color, getRandomColor } from '$lib/env/colors';
export interface UserProfile {
id: string
mastodon: {
profilePicture?: string
username?: string
instance?: {
domain: Instance['domain']
url: string
info: Instance['info']
}
token?: string
client?: {
id: string
secret: string
}
enabled: boolean
}
twitter: {
profilePicture?: string
username?: string
token?: string
enabled: boolean
}
config: {
color: Color
darkMode: boolean
}
state: {
text: string
}
}
export type UserMap = Map<string, UserProfile>;
export const currentUserId = persistent<UserProfile['id']>('twooth.logged-user', uuid());
export const usersMap = persistent<UserMap>(
'twooth.users',
(new Map<string, UserProfile>()).set(get(currentUserId), {
id: get(currentUserId),
twitter: { enabled: true },
mastodon: { enabled: true },
config: { color: getRandomColor(), darkMode: true },
state: { text: '' },
}),
(map) => JSON.stringify(
Object.fromEntries(map),
),
(string) => new Map<string, UserProfile>(
Object.entries(JSON.parse(string)),
),
);
export const currentUser = ((): {
subscribe(this: void, run: Subscriber<UserProfile>, invalidate?: any): Unsubscriber
set(value: UserProfile): void
update(callback: (storedProfile: UserProfile) => UserProfile): void
} => {
const { subscribe } = derived<Writable<UserProfile['id']>, UserProfile>(
currentUserId, $id => get(usersMap).get($id)!,
);
return {
subscribe,
set(value: UserProfile): void {
usersMap.update(storedMap => {
storedMap.set(get(currentUserId), value);
return storedMap;
});
},
update(callback: (storedProfile: UserProfile) => UserProfile): void {
this.set(
callback(
get(usersMap).get(get(currentUserId))!,
),
);
},
};
})();
export function createNewUser(): UserProfile {
const newUUID = uuid();
function getUniqueColor(): Color {
let color = getRandomColor();
get(usersMap).forEach(user => {
if (user.config.color === color) color = getUniqueColor();
});
return color;
}
usersMap.update(map => {
map.set(newUUID, {
id: newUUID,
twitter: { enabled: true },
mastodon: { enabled: true },
config: { color: getUniqueColor(), darkMode: true },
state: { text: '' },
});
return map;
});
return get(usersMap).get(newUUID)!;
}
export function switchUser(id: UserProfile['id']): void {
currentUserId.set(id);
}
export function deleteUser(id: UserProfile['id']): void {
usersMap.update($userMap => {
$userMap.delete(id);
return $userMap;
});
}
export const lastMastodonPost = writable<number | undefined>();
export const lastTwitterPost = writable<number | undefined>();

77
src/lib/types/mastodon.d.ts vendored Normal file
View File

@@ -0,0 +1,77 @@
export interface Instance {
domain: string
active_users: number
version: string
info?: {
max_characters: number
max_attached_files: number
supported_files: string[]
image_size_limit: number
video_size_limit: number
polls: {
max_options: number
max_character_per_option: number
min_expiration: number
max_expiration: number
}
version: string
}
}
export interface Client {
id: number
name: string
website: string | null
redirect_uri: string
client_id: string
client_secret: string
vapid_key: string
}
export interface AccountCredentials {
id: number
username: string
acct: string
display_name: string
locked: boolean
bot: boolean
discoverable: boolean
group: boolean
created_at: string
note: string
url: string
avatar: string
avatar_static: string
header: string
header_static: string
followers_count: number
following_count: number
statuses_count: number
last_status_at: string
noindex: boolean
source: {
privacy: string
sensitive: boolean
language: string | null
note: string
fields: {
name: string
value: string
verified_at: string
}[]
follow_request_count: number
}
emojis: {
shortcode: string
url: string
static_url: string
visible_in_picker: boolean
}[]
roles: any[]
fields: {
name: string
value: string
verified_at: string
}[]
}

6
src/lib/types/twitter.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export interface AccountCredentials {
id: number
profile_image_url: string
username: string
name: string
}

27
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,27 @@
<script lang="ts">
import Menu from "$lib/components/Menu/Menu.svelte";
import StateProvider from "$lib/providers/AppStateProvider.svelte";
import "virtual:uno.css";
</script>
<div
class="fixed block h-screen w-screen flex items-center justify-center overflow-hidden"
>
<StateProvider />
<section
class="min-h-30% w-full gap-x-5"
un-grid="~ cols-3 rows-1"
un-text="gray10"
un-font="bold inter"
un-absolute
>
<main class="order-2 h-full w-full justify-self-start">
<slot />
</main>
<aside class="order-1 justify-self-end">
<Menu />
</aside>
</section>
</div>
<style uno:preflights uno:safelist global></style>

7
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,7 @@
<script lang="ts">
import TwoothForm from './TwoothInput/Form.svelte';
</script>
<div>
<TwoothForm />
</div>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import RadialProgress from '$lib/components/RadialProgress.svelte';
import Textarea from '$lib/components/Textarea.svelte';
import { currentUser } from '$lib/state/users';
import PlatformSelection from './PlatformSelection.svelte';
import SubmitButton from './SubmitButton.svelte';
import { text } from './data';
let overLimit = false;
let characters = 0;
let characterLimit = 240;
text.subscribe((text) => {
currentUser.update((user) => {
user.state.text = text;
return user;
});
});
</script>
<div class="w-full">
<Textarea
placeholder="Make a twooth!"
name="twooth"
{characterLimit}
bind:value={$text}
bind:overLimit
bind:characters
required
/>
<menu class="h-10 flex items-center justify-between p-0 py-3">
<section>
<p class="m-0 mb-1 text-sm text-gray7">Post on:</p>
<div>
<PlatformSelection />
</div>
</section>
<section class="inline-flex items-center justify-between gap-3">
{#if characters > characterLimit / 3}
<span transition:fade={{ delay: 100, duration: 200 }}>
<RadialProgress
width="w-8"
height="h-8"
color={overLimit ? 'hue-red text-hue10' : 'text-hue7'}
class="inline-block"
label={`${characters} / ${characterLimit}`}
value={characters}
max={characterLimit}
/>
</span>
{/if}
{#if characters > 0}
<span transition:fade={{ delay: 100, duration: 200 }}>
<SubmitButton disabled={overLimit} />
</span>
{/if}
</section>
</menu>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import Checkbox from '$lib/components/Checkbox.svelte';
import { currentUser } from '$lib/state/users';
let twitterCheck = $currentUser.twitter.enabled ?? true;
let mastodonCheck = $currentUser.mastodon.enabled ?? true;
$: togglePlatform(twitterCheck, 'twitter');
$: togglePlatform(mastodonCheck, 'mastodon');
function togglePlatform(value: boolean, platform: 'twitter' | 'mastodon') {
currentUser.update((user) => {
user[platform] = { ...user[platform], enabled: value };
return user;
});
}
</script>
<div class="inline-flex items-center">
<Checkbox
bind:value={twitterCheck}
class="mr-5"
id="twitter"
disabled={$currentUser.twitter.token ? false : true}
>
<span class="i-custom:twitter inline-block text-xl" /> Twitter
</Checkbox>
<Checkbox
bind:value={mastodonCheck}
id="mastodon"
disabled={$currentUser.mastodon.token ? false : true}
>
<span class="i-custom:mastodon inline-block text-xl" /> Mastodon
</Checkbox>
</div>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { derived } from 'svelte/store';
import { submitTwooth } from './SubmitButton';
import { currentUser } from '$lib/state/users';
export let disabled = false;
const canPost = derived(currentUser, (user) => {
if (user.mastodon.token || user.twitter.token) return true;
else return false;
});
let isDisabled = disabled || !$canPost;
$: isDisabled = disabled || !$canPost;
</script>
<button
type="submit"
class:cursor-pointer={!isDisabled}
un-h="10"
un-gap="2"
un-font="bold"
un-inline-flex
un-justify="between"
un-items="center"
un-duration="200"
un-rounded="full"
un-outline="none"
un-border="none"
un-text={`hue9 ${isDisabled ? '' : 'focus:(hue12) hover:(hue12)'}`}
un-bg={`hue2 ${isDisabled ? '' : 'focus:(hue8) hover:(hue8)'}`}
on:click={() => (isDisabled ? null : submitTwooth())}
disabled={isDisabled}
>
<span id="label">Twooth!</span>
<span class="i-solar:plain-bold-duotone inline-block text-xl" />
</button>
<style scoped>
button {
padding: 0 0.7rem;
}
button span#label {
display: none;
padding: 0;
margin: 0;
}
button:hover:not([disabled]),
button:focus:not([disabled]) {
padding: 0 1.25rem;
}
button:hover:not([disabled]) span#label,
button:focus:not([disabled]) span#label {
display: initial;
padding: 0;
margin: 0;
}
</style>

View File

@@ -0,0 +1,46 @@
import { get } from 'svelte/store';
import { text } from './data';
import { currentUser, lastMastodonPost, lastTwitterPost } from '$lib/state/users';
import qFetch from '$lib/qFetch';
export async function submitTwooth(): Promise<void> {
const user = get(currentUser);
const success = [false, false];
if (user.mastodon.enabled) {
const { id } = await qFetch<{ id: number }>(
'/api/mastodon/post',
{
query: { instance: user.mastodon.instance?.url, status: get(text) },
options: {
headers: { Authorization: `Bearer ${user.mastodon.token}` },
method: 'POST',
},
},
);
lastMastodonPost.set(id);
success[0] = true;
} else {
lastMastodonPost.set(undefined);
}
if (user.twitter.enabled) {
const { id } = await qFetch<{ id: number }>(
'/api/twitter/post',
{
query: { status: get(text) },
options: {
headers: { Authorization: `Bearer ${user.twitter.token}` },
method: 'POST',
},
},
);
lastTwitterPost.set(id);
success[1] = true;
} else {
lastTwitterPost.set(undefined);
}
if (success[0] || success[1]) text.set('');
}

View File

@@ -0,0 +1,5 @@
import { get, writable } from 'svelte/store';
import { currentUser } from '$lib/state/users';
export const text = writable<string>(get(currentUser).state.text);
export const posted = writable<boolean>(false);

View File

@@ -0,0 +1,23 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import type { AccountCredentials } from '$lib/types/mastodon';
import qFetch from '$lib/qFetch';
export const GET = (async ({ request, url }): Promise<Response> => {
const token = request.headers.get('Authorization');
const instanceUrl = url.searchParams.get('instance');
if (!token || !instanceUrl) throw error(400);
const credentials = await qFetch<AccountCredentials>(
`${instanceUrl}/api/v1/accounts/verify_credentials`,
{ options: { headers: { Authorization: token } } },
);
return new Response(
JSON.stringify(credentials),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) satisfies RequestHandler;

View File

@@ -0,0 +1,33 @@
import qFetch from '$lib/qFetch';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST = (async ({ request, url }): Promise<Response> => {
const token = request.headers.get('Authorization');
const instanceUrl = url.searchParams.get('instance');
const status = url.searchParams.get('status');
if (!token || !instanceUrl || !status) throw error(400);
const postReq = await qFetch<{ id: number }>(
`${instanceUrl}/api/v1/statuses`,
{
query: { status },
options: {
headers: { Authorization: token },
method: 'POST',
},
},
);
if (postReq) return new Response(
JSON.stringify({ id: postReq.id }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
else return new Response('', { status: 500 });
}) satisfies RequestHandler;

View File

@@ -0,0 +1,24 @@
import type { AccountCredentials } from '$lib/types/twitter';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import qFetch from '$lib/qFetch';
export const GET = (async ({ request }): Promise<Response> => {
const token = request.headers.get('Authorization');
if (!token) throw error(400);
const credentials = await qFetch<{ data: AccountCredentials }>(
'https://api.twitter.com/2/users/me?user.fields=profile_image_url,username',
{ options: { headers: { Authorization: token } } },
);
console.log(credentials);
return new Response(
JSON.stringify(credentials.data),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) satisfies RequestHandler;

View File

@@ -0,0 +1,32 @@
import qFetch from '$lib/qFetch';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST = (async ({ request, url }): Promise<Response> => {
const token = request.headers.get('Authorization');
const status = url.searchParams.get('status');
if (!token || !status) throw error(400);
const postReq = await qFetch<{ data: { id: number } }>(
'https://api.twitter.com/2/tweets',
{
options: {
headers: { Authorization: token },
method: 'POST',
body: { text: status },
},
},
);
if (postReq) return new Response(
JSON.stringify({ id: postReq.data.id }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
else return new Response('', { status: 500 });
}) satisfies RequestHandler;

View File

@@ -0,0 +1,5 @@
<div
un-w="full" un-h="full" un-bg="gray2" un-p="5"
un-rounded="xl">
<slot />
</div>

View File

@@ -0,0 +1,61 @@
import { fail, redirect, type ActionFailure, type Redirect } from '@sveltejs/kit';
import { withHttps, withQuery } from 'ufo';
import type { Actions, PageServerLoad } from './$types';
import type { Client, Instance } from '$lib/types/mastodon';
import { BASE_URL, MASTODON_REDIRECT, MASTODON_SCOPES } from '$lib/env/globals';
import { instanceList, getInstanceInfo } from './instances';
import qFetch from '$lib/qFetch';
export const load = ((): { instances: Instance[] } => {
return { instances: instanceList };
}) satisfies PageServerLoad;
export const actions = {
default: async ({ request, url }): Promise<ActionFailure<any> | Redirect> => {
const data = await request.formData();
const instanceInput = data.get('Instance Search');
if (!instanceInput) return fail(400, { instanceInput, missing: true });
const instanceUrl = withHttps(instanceInput.toString());
const instance = await getInstance(instanceUrl);
if (!instance) return fail(400, { instanceInput, incorrect: true });
const client = await getClient(instanceUrl);
if (!client) return fail(400, { instanceInput, incorrect: true });
throw redirect(304, withQuery(
url.pathname, {
client_id: client.client_id,
client_secret: client.client_secret,
client_instance: instance,
},
));
},
} satisfies Actions;
async function getInstance(instanceUrl: string): Promise<Instance | undefined> {
const fromList = instanceList.find(instance => instance.domain.toLowerCase() === instanceUrl);
if (fromList?.info) {
return fromList;
}
const instance = await getInstanceInfo(instanceUrl);
return instance;
}
async function getClient(instanceUrl: string): Promise<Client | undefined> {
return qFetch<Client>(`${instanceUrl}/api/v1/apps`, {
query: {
client_name: 'Twooth',
redirect_uris: MASTODON_REDIRECT,
scopes: MASTODON_SCOPES,
website: BASE_URL,
},
options: { method: 'POST' },
}).catch(() => undefined);
}

View File

@@ -0,0 +1,177 @@
<script lang="ts">
/**
* TODO: Add support for keyboard navigation
*/
import { page } from '$app/stores';
import { derived, writable } from 'svelte/store';
import type { ActionData, PageData } from './$types';
import { enhance } from '$app/forms';
import ButtonIcon from '$lib/components/ButtonIcon.svelte';
import { currentUser } from '$lib/state/users';
import type { Instance } from '$lib/types/mastodon';
import { withHttps, withQuery } from 'ufo';
import { goto } from '$app/navigation';
import { MASTODON_REDIRECT, MASTODON_SCOPES } from '$lib/env/globals';
export let data: PageData;
export let form: ActionData;
const searchTerm = writable('');
const filteredInstances = derived(searchTerm, ($searchTerm) => {
if (!data.instances) return [];
if ($searchTerm === '') return data.instances;
return data.instances.filter((instance) => instance.domain.toLowerCase().includes($searchTerm.toLowerCase()));
});
const client = derived(page, ({ url }) => {
const id = url.searchParams.get('client_id');
const secret = url.searchParams.get('client_secret');
const instance = url.searchParams.get('client_instance');
if (!id || !secret || !instance) return undefined;
return {
id: id,
secret: secret,
instance: JSON.parse(instance) as Instance,
};
});
$: if ($client) {
currentUser.update((user) => {
user.mastodon['client'] = {
id: $client?.id!,
secret: $client?.secret!,
};
user.mastodon['instance'] = {
info: $client?.instance.info,
url: withHttps($client?.instance!.domain!),
domain: $client?.instance!.domain!,
};
return user;
});
authorize(
$currentUser.mastodon.instance?.url!,
$currentUser.mastodon.client?.id!,
);
}
async function authorize(instanceUrl: string, clientId: string) {
goto(
withQuery(`${instanceUrl}/oauth/authorize`, {
response_type: 'code',
client_id: clientId,
redirect_uri: MASTODON_REDIRECT,
scope: MASTODON_SCOPES,
}),
);
}
</script>
<form method="post" un-relative use:enhance>
{#if $client}
<section
un-flex
un-justify-center
un-items-center
un-left="0"
un-top="0"
un-z="1"
>
<div un-bg="gray2" un-text="center" un-p="10" un-rounded="xl">
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-solar:check-circle-line-duotone"
/>
<h1>Client created!</h1>
<p un-text="sm gray8">Redirecting...</p>
</div>
</section>
{:else}
<label un-block un-m="b-2" for="instance-search">Instance domain:</label>
<div un-flex un-items-center un-gap-3>
<div un-w="full">
<input
un-w="full"
un-p="3 l-3"
un-block
un-rounded="xl"
un-outline="0"
un-border="0"
un-text="hue11 xl"
un-bg="gray3"
un-placeholder="gray6"
type="search"
autocomplete="off"
name="Instance Search"
id="instance-search"
placeholder="mastodon.social"
required="true"
bind:value={$searchTerm}
/>
</div>
<div>
<ButtonIcon icon="i-solar:square-top-down-bold-duotone" type="submit">
Login
</ButtonIcon>
</div>
</div>
{#if form?.incorrect}
<div
un-bg="tomato1"
un-p="2"
un-rounded="xl"
un-outline="tomato9 2 solid"
>
<p class="m-0 text-tomato9">Please enter a valid instance domain</p>
</div>
{/if}
<select
un-block
un-w="full"
un-p="2 b-0"
un-b="0"
un-appearance="none"
un-outline="none"
un-overflow="hidden"
un-bg="transparent"
un-text="xl gray9"
size="7"
name="Instance Selection"
id="instance-selection"
>
{#if $filteredInstances.length > 0}
{#each $filteredInstances as instance}
<option
un-p="y-1"
un-bg="transparent"
un-appearance="none"
un-text="gray7 focus:(hue10) hover:(hue10)"
value={instance}
selected={$filteredInstances.indexOf(instance) === 0}
on:click={() => ($searchTerm = instance.domain)}
>
{ instance.domain }
</option>
{/each}
{:else}
<option
un-p="y-1"
un-appearance="none"
un-bg="transparent"
un-text="gray7 focus:(hue10) hover:(hue10)"
selected="0"
value={$searchTerm}
on:click={() => ($searchTerm = $searchTerm)}
>
{ $searchTerm }
</option>
{/if}
</select>
{/if}
</form>

View File

@@ -0,0 +1,128 @@
import { withHttps } from 'ufo';
import { PRIVATE_INSTANCES_API_TOKEN } from '$env/static/private';
import type { Instance } from '$lib/types/mastodon';
import qFetch from '$lib/qFetch';
export const instanceList = await ((): Promise<Instance[]> => {
try {
return getDynamicList();
} catch (err) {
console.error(`ERROR:\n${err}\n(Getting static list instead)`);
return getStaticList();
}
})();
export async function getDynamicList(): Promise<Instance[]> {
const { instances } = await qFetch<{
instances: {
name: string
active_users: number
version: string | undefined
[other: string]: any
}[]
}>('https://instances.social/api/1.0/instances/list', {
query: {
sort_by: 'active_users',
sort_order: 'desc',
count: '100',
},
options: { headers: { Authorization: `Bearer ${PRIVATE_INSTANCES_API_TOKEN}` } },
});
const filteredInstances = instances.filter<{
name: string
active_users: number
version: string
[other: string]: any
// @ts-expect-error
}>(i => {
return i.version?.startsWith('4');
});
return filteredInstances.map(i => {
return {
domain: i.name,
active_users: i.active_users,
version: i.version,
};
});
};
export async function getStaticList(): Promise<Instance[]> {
/**
* Instances picked based on active and total user number;
* name similar to mastodon (for easier searching);
* ~~and randomly.~~
*/
const instanceDomains = [
'mastodon.social',
'mstdn.jp',
'mastodon.cloud',
'mastodon.online',
'counter.social',
'mstdn.social',
'mastodon.world',
'home.social',
'fosstodon.org',
'social.vivaldi.net',
'mastodon.art',
'techhub.social',
'mastodon.lol',
'toot.community',
'mastodon.scot',
'mastodon.xyz',
'mastodon.au',
'mastodon.gamedev.place',
'mastodon.ie',
'masto.nu',
'awscommunity.social',
'mstdn.plus',
];
const instancesList: Instance[] = [];
for (const instanceDomain of instanceDomains) {
const instanceInfo = await getInstanceInfo(withHttps(instanceDomain));
if (!instanceInfo) continue;
if (!instanceInfo.version.startsWith('4')) continue;
instancesList.push(instanceInfo);
}
return instancesList;
}
export async function getInstanceInfo(instanceUrl: string): Promise<Instance | undefined> {
const query = await qFetch<{
domain: string
description: string
usage: { users: { active_month: number } }
[other: string]: any
}>(`${instanceUrl}/api/v2/instance`).catch(() => undefined);
console.log('QUERY ' + query);
if (!query) return undefined;
return {
domain: query.domain,
active_users: query.usage.users.active_month,
version: query.version,
info: {
max_characters: query.configuration.statuses.max_characters,
max_attached_files: query.configuration.statuses.max_media_attachments,
supported_files: query.configuration.media_attachments.supported_mime_types,
image_size_limit: query.configuration.media_attachments.image_size_limit,
video_size_limit: query.configuration.media_attachments.video_size_limit,
polls: {
max_options: query.configuration.polls.max_options,
max_character_per_option: query.configuration.polls.max_character_per_option,
min_expiration: query.configuration.polls.min_expiration,
max_expiration: query.configuration.polls.max_expiration,
},
version: query.version,
},
};
}

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { goto } from '$app/navigation';
import qFetch from '$lib/qFetch';
import { currentUser, type UserProfile } from '$lib/state/users';
import type { AccountCredentials as MastodonAccountCredentials } from '$lib/types/mastodon';
import type { PageData } from './$types';
export let data: PageData;
async function getToken(): Promise<string> {
const token: UserProfile['mastodon']['token'] = await qFetch(
"/mastodon/oauth/token",
{
query: {
client_id: $currentUser.mastodon.client?.id,
client_secret: $currentUser.mastodon.client?.secret,
instance: $currentUser.mastodon.instance?.url,
code: data.code,
},
options: { method: 'GET' },
}
);
return token ?? '';
}
async function getUserInfo(): Promise<void> {
const token = await getToken();
const credentials = await qFetch<MastodonAccountCredentials>(
"/api/mastodon/credentials",
{
query: { instance: $currentUser.mastodon.instance?.url },
options: { headers: { Authorization: `Bearer ${token}` } },
}
);
currentUser.update((user) => {
user.mastodon.username = credentials.username;
user.mastodon.profilePicture = credentials.avatar;
user.mastodon.token = token;
return user;
});
goto('/', { invalidateAll: true, replaceState: true });
}
</script>
<section
un-flex
un-justify-center
un-items-center
un-left="0"
un-top="0"
un-w="full"
un-h="full"
un-z="1"
>
<div un-text="center" un-p="10">
{#await getUserInfo()}
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-solar:lock-password-unlocked-line-duotone"
/>
<h1>Getting authorization token...</h1>
<p un-text="sm gray8" />
{:then _}
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-solar:lock-password-line-duotone"
/>
<h1>Token returned</h1>
<p un-text="sm gray8">Redirecting...</p>
{/await}
</div>
</section>

View File

@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (({ url }): { code: string } => {
const code = url.searchParams.get('code');
if (!code) throw error(400);
return { code };
}) satisfies PageLoad;

View File

@@ -0,0 +1,35 @@
import { MASTODON_REDIRECT } from '$lib/env/globals';
import qFetch from '$lib/qFetch';
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
export const GET = (async ({ url }): Promise<Response> => {
const clientId = url.searchParams.get('client_id');
const clientSecret = url.searchParams.get('client_secret');
const instanceUrl = url.searchParams.get('instance');
const code = url.searchParams.get('code');
if (!clientId || !clientSecret || !instanceUrl || !code) throw error(400);
const token = await qFetch<{
access_token: string
token_type: 'Bearer'
scope: string
created_at: number
}>(`${instanceUrl}/oauth/token`, {
query: {
grant_type: 'authorization_code',
code: code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: MASTODON_REDIRECT,
},
options: { method: 'POST' },
});
if (!token) throw error(500);
return new Response(token.access_token, { status: 200, headers: { 'Content-Type': 'text/plain' } });
}) satisfies RequestHandler;

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { withQuery } from 'ufo';
import { onMount } from 'svelte';
import { PUBLIC_TWITTER_OAUTH2_API_ID } from '$env/static/public';
import { TWITTER_REDIRECT } from '$lib/env/globals';
const url = withQuery('https://twitter.com/i/oauth2/authorize', {
response_type: 'code',
client_id: PUBLIC_TWITTER_OAUTH2_API_ID,
redirect_uri: TWITTER_REDIRECT,
scope: 'tweet.read tweet.write users.read',
code_challenge: 'challenge',
code_challenge_method: 'plain',
state: 'state',
});
onMount(() => {
goto(url);
});
</script>
<section
un-flex
un-justify-center
un-items-center
un-w="full"
un-h="full"
un-bg="gray2"
un-rounded="xl"
>
<div un-text="center" un-p="10">
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-svg-spinners:180-ring-with-bg"
/>
<h1>Logging into twitter</h1>
<p un-text="sm gray8">Redirecting...</p>
</div>
</section>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { goto } from '$app/navigation';
import qFetch from '$lib/qFetch';
import { currentUser, type UserProfile } from '$lib/state/users';
import type { AccountCredentials as TwitterAccountCredentials } from '$lib/types/twitter';
import type { PageData } from './$types';
export let data: PageData;
async function getToken(): Promise<string> {
const token: UserProfile['mastodon']['token'] = await qFetch(
"/twitter/oauth/token",
{
query: { code: data.code },
options: { method: 'GET' },
}
);
return token ?? '';
}
async function getUserInfo(): Promise<void> {
const token = await getToken();
const credentials = await qFetch<TwitterAccountCredentials>(
"/api/twitter/credentials",
{ options: { headers: { Authorization: `Bearer ${token}` } } },
);
currentUser.update((user) => {
user.twitter.username = credentials.username;
user.twitter.profilePicture = credentials.profile_image_url;
user.twitter.token = token;
return user;
});
goto('/', { invalidateAll: true, replaceState: true });
}
</script>
<section
un-flex
un-justify-center
un-items-center
un-left="0"
un-top="0"
un-w="full"
un-h="full"
un-z="1"
>
<div un-text="center" un-p="10">
{#await getUserInfo()}
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-solar:lock-password-unlocked-line-duotone"
/>
<h1>Getting authorization token...</h1>
<p un-text="sm gray8" />
{:then _}
<span
un-block
un-m="x-auto"
un-text="7xl hue8"
class="i-solar:lock-password-line-duotone"
/>
<h1>Token returned</h1>
<p un-text="sm gray8">Redirecting...</p>
{/await}
</div>
</section>

View File

@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (({ url }): { code: string } => {
const code = url.searchParams.get('code');
if (!code) throw error(400);
return { code };
}) satisfies PageLoad;

View File

@@ -0,0 +1,34 @@
import qFetch from '$lib/qFetch';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { PUBLIC_TWITTER_OAUTH2_API_ID } from '$env/static/public';
import { TWITTER_REDIRECT } from '$lib/env/globals';
export const GET = (async ({ url }): Promise<Response> => {
const code = url.searchParams.get('code');
if (!code) throw error(400);
const token = await qFetch<{
token_type: 'bearer'
expires_in: number
access_token: string
scope: string
}>(
'https://api.twitter.com/2/oauth2/token',
{
query: {
code: code,
grant_type: 'authorization_code',
client_id: PUBLIC_TWITTER_OAUTH2_API_ID,
redirect_uri: TWITTER_REDIRECT,
code_verifier: 'challenge',
challenge_method: 'plain',
},
options: { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method: 'POST' },
},
);
return new Response(token.access_token, { status: 200, headers: { 'Content-Type': 'text/plain' } });
}) satisfies RequestHandler;

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

19
svelte.config.js Normal file
View File

@@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-vercel';
import { vitePreprocess } from '@sveltejs/kit/vite';
import { preprocessor as documentPreprocessor } from '@sveltekit-addons/document';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [vitePreprocess(), documentPreprocessor()],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
},
};
export default config;

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
}

63
unocss.config.ts Normal file
View File

@@ -0,0 +1,63 @@
import {
presetAttributify,
presetIcons,
presetTypography,
presetUno, presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
import extractorSvelte from '@unocss/extractor-svelte';
import { presetRadix } from 'unocss-preset-radix';
import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders';
import colors from './src/lib/env/colors';
/** @type {import('@unocss/vite').VitePluginConfig} */
const config = {
// mode: 'svelte-scoped',
extractors: [extractorSvelte()],
presets: [
presetAttributify(),
presetIcons({
collections: {
'solar': () => import('@iconify-json/solar/icons.json')
.then(i => i.default as any),
'svg-spinners': () => import('@iconify-json/svg-spinners/icons.json')
.then(i => i.default as any),
'custom': FileSystemIconLoader('./src/assets/icons'),
},
}),
presetTypography(),
presetWebFonts({
fonts: {
inter: [
{
name: 'Inter',
provider: 'bunny',
},
{
name: 'sans-serif',
provider: 'none',
},
],
},
}),
presetUno(),
presetRadix({ palette: colors }),
],
transformers: [
transformerDirectives(),
transformerVariantGroup(),
],
safelist: [
...colors.map(c => `hue-${c}`),
'bg-[#161616]',
'text-[#161616]',
'transition-all',
'delay-500',
],
};
export default config;

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import UnoCss from 'unocss/vite';
export default defineConfig({
plugins: [UnoCss(), sveltekit()],
server: { port: 3000, host: true },
});