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:
13
.env.example
Normal file
13
.env.example
Normal 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
13
.eslintignore
Normal 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
24
.eslintrc.cjs
Normal 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
14
.gitignore
vendored
Normal 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
|
||||
17
.vscode/settings.json
vendored
Normal file
17
.vscode/settings.json
vendored
Normal 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
38
README.md
Normal 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
46
package.json
Normal 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
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
12
src/app.d.ts
vendored
Normal 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
15
src/app.html
Normal 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>
|
||||
4
src/assets/icons/mastodon.svg
Normal file
4
src/assets/icons/mastodon.svg
Normal 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 |
9
src/assets/icons/twitter.svg
Normal file
9
src/assets/icons/twitter.svg
Normal 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
4
src/env.d.ts
vendored
Normal 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
22
src/hooks.server.ts
Normal 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);
|
||||
22
src/lib/components/ButtonIcon.svelte
Normal file
22
src/lib/components/ButtonIcon.svelte
Normal 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>
|
||||
49
src/lib/components/Checkbox.svelte
Normal file
49
src/lib/components/Checkbox.svelte
Normal 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>
|
||||
67
src/lib/components/Menu/Menu.svelte
Normal file
67
src/lib/components/Menu/Menu.svelte
Normal 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>
|
||||
181
src/lib/components/Menu/User.svelte
Normal file
181
src/lib/components/Menu/User.svelte
Normal 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>
|
||||
73
src/lib/components/Menu/UserDelete.svelte
Normal file
73
src/lib/components/Menu/UserDelete.svelte
Normal 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}
|
||||
69
src/lib/components/Menu/Users.svelte
Normal file
69
src/lib/components/Menu/Users.svelte
Normal 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>
|
||||
34
src/lib/components/RadialProgress.svelte
Normal file
34
src/lib/components/RadialProgress.svelte
Normal 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>
|
||||
33
src/lib/components/Textarea.svelte
Normal file
33
src/lib/components/Textarea.svelte
Normal 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
33
src/lib/env/colors.ts
vendored
Normal 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
4
src/lib/env/globals.ts
vendored
Normal 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';
|
||||
22
src/lib/providers/AppStateProvider.svelte
Normal file
22
src/lib/providers/AppStateProvider.svelte
Normal 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
14
src/lib/qFetch.ts
Normal 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
23
src/lib/state/config.ts
Normal 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
38
src/lib/state/store.ts
Normal 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
138
src/lib/state/users.ts
Normal 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
77
src/lib/types/mastodon.d.ts
vendored
Normal 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
6
src/lib/types/twitter.d.ts
vendored
Normal 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
27
src/routes/+layout.svelte
Normal 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
7
src/routes/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import TwoothForm from './TwoothInput/Form.svelte';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<TwoothForm />
|
||||
</div>
|
||||
61
src/routes/TwoothInput/Form.svelte
Normal file
61
src/routes/TwoothInput/Form.svelte
Normal 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>
|
||||
35
src/routes/TwoothInput/PlatformSelection.svelte
Normal file
35
src/routes/TwoothInput/PlatformSelection.svelte
Normal 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>
|
||||
61
src/routes/TwoothInput/SubmitButton.svelte
Normal file
61
src/routes/TwoothInput/SubmitButton.svelte
Normal 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>
|
||||
46
src/routes/TwoothInput/SubmitButton.ts
Normal file
46
src/routes/TwoothInput/SubmitButton.ts
Normal 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('');
|
||||
|
||||
}
|
||||
5
src/routes/TwoothInput/data.ts
Normal file
5
src/routes/TwoothInput/data.ts
Normal 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);
|
||||
23
src/routes/api/mastodon/credentials/+server.ts
Normal file
23
src/routes/api/mastodon/credentials/+server.ts
Normal 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;
|
||||
33
src/routes/api/mastodon/post/+server.ts
Normal file
33
src/routes/api/mastodon/post/+server.ts
Normal 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;
|
||||
|
||||
24
src/routes/api/twitter/credentials/+server.ts
Normal file
24
src/routes/api/twitter/credentials/+server.ts
Normal 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;
|
||||
32
src/routes/api/twitter/post/+server.ts
Normal file
32
src/routes/api/twitter/post/+server.ts
Normal 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;
|
||||
|
||||
5
src/routes/mastodon/+layout.svelte
Normal file
5
src/routes/mastodon/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<div
|
||||
un-w="full" un-h="full" un-bg="gray2" un-p="5"
|
||||
un-rounded="xl">
|
||||
<slot />
|
||||
</div>
|
||||
61
src/routes/mastodon/login/+page.server.ts
Normal file
61
src/routes/mastodon/login/+page.server.ts
Normal 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);
|
||||
}
|
||||
177
src/routes/mastodon/login/+page.svelte
Normal file
177
src/routes/mastodon/login/+page.svelte
Normal 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>
|
||||
128
src/routes/mastodon/login/instances.ts
Normal file
128
src/routes/mastodon/login/instances.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
80
src/routes/mastodon/oauth/+page.svelte
Normal file
80
src/routes/mastodon/oauth/+page.svelte
Normal 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>
|
||||
10
src/routes/mastodon/oauth/+page.ts
Normal file
10
src/routes/mastodon/oauth/+page.ts
Normal 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;
|
||||
35
src/routes/mastodon/oauth/token/+server.ts
Normal file
35
src/routes/mastodon/oauth/token/+server.ts
Normal 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;
|
||||
|
||||
42
src/routes/twitter/login/+page.svelte
Normal file
42
src/routes/twitter/login/+page.svelte
Normal 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>
|
||||
73
src/routes/twitter/oauth/+page.svelte
Normal file
73
src/routes/twitter/oauth/+page.svelte
Normal 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>
|
||||
|
||||
10
src/routes/twitter/oauth/+page.ts
Normal file
10
src/routes/twitter/oauth/+page.ts
Normal 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;
|
||||
34
src/routes/twitter/oauth/token/+server.ts
Normal file
34
src/routes/twitter/oauth/token/+server.ts
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
19
svelte.config.js
Normal file
19
svelte.config.js
Normal 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
13
tsconfig.json
Normal 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
63
unocss.config.ts
Normal 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
8
vite.config.ts
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user