Skip to content

Commit

Permalink
Add basic admin UI
Browse files Browse the repository at this point in the history
  • Loading branch information
KevSlashNull committed Jul 19, 2023
1 parent 42d9f3e commit d03bd2d
Show file tree
Hide file tree
Showing 21 changed files with 7,338 additions and 1,024 deletions.
23 changes: 23 additions & 0 deletions web/admin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Nuxt dev/build outputs
.output
.nuxt
.nitro
.cache
dist

# Node dependencies
node_modules

# Logs
logs
*.log

# Misc
.DS_Store
.fleet
.idea

# Local env files
.env
.env.*
!.env.example
2 changes: 2 additions & 0 deletions web/admin/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false
63 changes: 63 additions & 0 deletions web/admin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Nuxt 3 Minimal Starter

Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.

## Setup

Make sure to install the dependencies:

```bash
# npm
npm install

# pnpm
pnpm install

# yarn
yarn install
```

## Development Server

Start the development server on `http://localhost:3000`:

```bash
# npm
npm run dev

# pnpm
pnpm run dev

# yarn
yarn dev
```

## Production

Build the application for production:

```bash
# npm
npm run build

# pnpm
pnpm run build

# yarn
yarn build
```

Locally preview production build:

```bash
# npm
npm run preview

# pnpm
pnpm run preview

# yarn
yarn preview
```

Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
70 changes: 70 additions & 0 deletions web/admin/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script setup>
import * as auth from "~/lib/auth";
useHead({
title: "furryli.st Admin",
meta: [{ name: "robots", content: "noindex" }],
link: [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com" },
{
href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
rel: "stylesheet",
},
],
bodyAttrs: {
class: "bg-white dark:bg-gray-900 dark:text-white",
},
});
const identifier = ref();
const password = ref();
const error = ref();
const user = await useUser();
async function login() {
error.value = null;
const isSignedIn = await auth
.login(identifier.value, password.value)
.catch((error) => ({ error }));
error.value = !isSignedIn;
}
</script>

<template>
<NuxtPage v-if="user" />
<div class="flex items-center justify-center fixed w-full h-full" v-else>
<div
class="mx-auto bg-gray-50 border border-gray-400 dark:border-gray-700 dark:bg-gray-800 py-4 px-5 rounded-lg w-[400px] max-w-[80vw]"
>
<h1 class="text-3xl font-bold mb-4">Login</h1>

<div class="flex flex-col mb-4">
<label class="font-bold mb-1" for="name">Handle</label>
<input
class="bg-white dark:bg-gray-900 rounded border border-gray-400 dark:border-gray-700 px-2 py-1"
id="name"
type="text"
v-model="identifier"
/>
</div>

<div class="flex flex-col mb-4">
<label class="font-bold mb-1" for="password">App password</label>
<input
class="bg-white dark:bg-gray-900 rounded border border-gray-400 dark:border-gray-700 px-2 py-1"
id="password"
type="password"
v-model="password"
/>
</div>

<div class="flex">
<button class="ml-auto px-3 py-2 rounded-lg bg-blue-600" @click="login">
Login
</button>
</div>
</div>
</div>
</template>
35 changes: 35 additions & 0 deletions web/admin/components/core/nav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup>
import { logout } from "~/lib/auth";
</script>

<template>
<nav
class="flex items-center gap-2 border border-gray-300 dark:border-gray-700 rounded-lg px-4 py-3 mb-5"
>
<nuxt-link href="/" class="mr-1">
<img
class="rounded-lg"
src="/icon-32.webp"
height="32"
width="32"
alt=""
/>
</nuxt-link>
<h1 class="text-xl font-bold">Admin</h1>
<div class="ml-auto flex items-center gap-2">
<img
class="rounded-full"
src="https://cdn.bsky.social/imgproxy/eJpUb3-QB55Yq73mMNdtgroGSVAcbjFB_55EgaNb6HE/rs:fill:1000:1000:1:0/plain/bafkreibhdnzijesikan6bpvmobvhunhtwyvthna32yyyhniosea2hkwa5e@jpeg"
height="32"
width="32"
alt=""
/>
<button
class="text-white bg-gray-700 px-2 py-1 rounded-lg"
@click="logout"
>
Logout
</button>
</div>
</nav>
</template>
31 changes: 31 additions & 0 deletions web/admin/components/shared/avatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts" setup>
defineProps<{ url?: string }>();
</script>

<template>
<img
v-if="url"
class="rounded-full"
:src="url"
height="64"
width="64"
alt=""
/>
<svg
v-else
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="none"
>
<circle cx="12" cy="12" r="12" fill="#0070ff"></circle>
<circle cx="12" cy="9.5" r="3.5" fill="#fff"></circle>
<path
stroke-linecap="round"
stroke-linejoin="round"
fill="#fff"
d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
></path>
</svg>
</template>
24 changes: 24 additions & 0 deletions web/admin/components/shared/bsky/description.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script lang="ts" setup>
import { RichText } from "@atproto/api";
import { newAgent } from "~/lib/auth";
const props = defineProps<{ description: string }>();
const segments = ref();
onMounted(async () => {
const descriptionRichText = new RichText(
{ text: props.description },
{ cleanNewlines: true }
);
await descriptionRichText.detectFacets(newAgent());
segments.value = [...descriptionRichText.segments()];
});
</script>
<template>
<div>
<shared-bsky-text v-for="segment in segments" :segment="segment" />
<div v-if="!segments" class="whitespace-pre-line">
{{ description }}
</div>
</div>
</template>
25 changes: 25 additions & 0 deletions web/admin/components/shared/bsky/text.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { RichTextSegment } from "@atproto/api";
defineProps<{ segment: RichTextSegment }>();
</script>

<template>
<nuxt-link
v-if="segment.isLink()"
class="underline hover:no-underline text-blue-500"
:href="segment.link?.uri"
target="_blank"
>
{{ segment.text }}
</nuxt-link>
<nuxt-link
v-else-if="segment.isMention()"
class="underline hover:no-underline text-blue-500"
:href="`https://bsky.app/profile/${segment.mention?.did}`"
target="_blank"
>
{{ segment.text }}
</nuxt-link>
<span class="whitespace-pre-line" v-else>{{ segment.text }}</span>
</template>
5 changes: 5 additions & 0 deletions web/admin/components/shared/card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div class="border border-gray-300 dark:border-gray-700 rounded-lg px-4 py-3">
<slot />
</div>
</template>
74 changes: 74 additions & 0 deletions web/admin/components/user-card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script lang="ts" setup>
import { newAgent } from "~/lib/auth";
const props = defineProps<{ did: string }>();
defineEmits(["accept", "reject"]);
const agent = newAgent();
const { data } = await agent.getProfile({
actor: props.did,
});
</script>

<template>
<shared-card v-if="data">
<div class="flex gap-3 items-center">
<shared-avatar :url="data.avatar" />
<div class="flex flex-col">
<div class="text-lg">{{ data.displayName || data.handle }}</div>
<div class="meta">
<span class="meta-item">
<nuxt-link
class="underline hover:no-underline text-gray-600 dark:text-gray-400"
:href="`https://bsky.app/profile/${data.handle}`"
target="_blank"
>@{{ data.handle }}</nuxt-link
>
</span>
<span class="meta-item">
{{ data.followersCount }}
<span class="text-gray-600 dark:text-gray-400">followers</span>
</span>
<span class="meta-item">
{{ data.followsCount }}
<span class="text-gray-600 dark:text-gray-400">follows</span>
</span>
<span class="meta-item">
{{ data.postsCount }}
<span class="text-gray-600 dark:text-gray-400">posts</span>
</span>
</div>
</div>
</div>
<div class="mt-5">
<shared-bsky-description
v-if="data.description"
:description="data.description"
/>
</div>
<div class="flex gap-3 mt-5">
<button
class="px-3 py-2 bg-blue-400 dark:bg-blue-500 rounded-lg"
@click="$emit('accept')"
>
Accept
</button>
<button
class="px-3 py-2 bg-red-500 dark:bg-red-600 rounded-lg"
@click="$emit('reject')"
>
Reject
</button>
</div>
</shared-card>
<shared-card class="bg-red-200 dark:bg-red-700" v-else>
Profile with did {{ did }} was not found.
</shared-card>
</template>

<style scoped>
.meta .meta-item:not(:last-child)::after {
content: " · ";
text-decoration: none;
}
</style>
21 changes: 21 additions & 0 deletions web/admin/composables/useAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createPromiseClient } from "@bufbuild/connect";
import { createConnectTransport } from "@bufbuild/connect-web";
import { ModerationService } from "../../proto/bff/v1/moderation_service_connectweb";

export default async function () {
const user = await useUser();
const transport = createConnectTransport({
baseUrl: "https://feed.furryli.st",

fetch(input, data = {}) {
(data.headers as Headers).set(
"authorization",
`Bearer ${user.value.accessJwt}`
);

return globalThis.fetch(input, { ...data });
},
});

return createPromiseClient(ModerationService, transport);
}
13 changes: 13 additions & 0 deletions web/admin/composables/useUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { AtpSessionData } from "@atproto/api";
import { COOKIE_NAME, fetchUser } from "~/lib/auth";

export default async function (): Promise<Ref<AtpSessionData>> {
const user = useState("user");
const session = useCookie(COOKIE_NAME).value;

if (session) {
user.value = await fetchUser();
}

return user as Ref<AtpSessionData>;
}
Loading

0 comments on commit d03bd2d

Please sign in to comment.