mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
add nuxt and expo with orpc
This commit is contained in:
49
apps/cli/templates/api/orpc/native/utils/orpc.ts.hbs
Normal file
49
apps/cli/templates/api/orpc/native/utils/orpc.ts.hbs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { createORPCReactQueryUtils } from "@orpc/react-query";
|
||||
import type { RouterUtils } from "@orpc/react-query";
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { appRouter } from "../../server/src/routers";
|
||||
{{#if auth}}
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{/if}}
|
||||
|
||||
type ORPCReactUtils = RouterUtils<RouterClient<typeof appRouter>>;
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
console.log(error)
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const link = new RPCLink({
|
||||
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/rpc`,
|
||||
{{#if auth}}
|
||||
headers() {
|
||||
const headers = new Map<string, string>();
|
||||
const cookies = authClient.getCookie();
|
||||
if (cookies) {
|
||||
headers.set("Cookie", cookies);
|
||||
}
|
||||
return Object.fromEntries(headers);
|
||||
},
|
||||
{{/if}}
|
||||
});
|
||||
|
||||
export const client: RouterClient<typeof appRouter> = createORPCClient(link);
|
||||
|
||||
export const orpc = createORPCReactQueryUtils(client);
|
||||
|
||||
export const ORPCContext = createContext<ORPCReactUtils | undefined>(undefined);
|
||||
|
||||
export function useORPC(): ORPCReactUtils {
|
||||
const orpc = useContext(ORPCContext);
|
||||
if (!orpc) {
|
||||
throw new Error("ORPCContext is not set up properly");
|
||||
}
|
||||
return orpc;
|
||||
}
|
||||
35
apps/cli/templates/api/orpc/web/nuxt/app/plugins/orpc.ts.hbs
Normal file
35
apps/cli/templates/api/orpc/web/nuxt/app/plugins/orpc.ts.hbs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
|
||||
import type { RouterClient } from '@orpc/server'
|
||||
import type { appRouter } from "../../../server/src/routers/index";
|
||||
import { createORPCClient } from '@orpc/client'
|
||||
import { RPCLink } from '@orpc/client/fetch'
|
||||
import { createORPCVueQueryUtils } from '@orpc/vue-query'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig()
|
||||
const serverUrl = config.public.serverURL
|
||||
|
||||
const rpcUrl = `${serverUrl}/rpc`;
|
||||
|
||||
const rpcLink = new RPCLink({
|
||||
url: rpcUrl,
|
||||
{{#if auth}}
|
||||
fetch(url, options) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
{{/if}}
|
||||
})
|
||||
|
||||
|
||||
const client: RouterClient<typeof appRouter> = createORPCClient(rpcLink)
|
||||
const orpcUtils = createORPCVueQueryUtils(client)
|
||||
|
||||
return {
|
||||
provide: {
|
||||
orpc: orpcUtils
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
{{#if auth}}
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{/if}}
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
@@ -10,6 +12,7 @@ const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
|
||||
{{#if auth}}
|
||||
headers() {
|
||||
const headers = new Map<string, string>();
|
||||
const cookies = authClient.getCookie();
|
||||
@@ -18,6 +21,7 @@ const trpcClient = createTRPCClient<AppRouter>({
|
||||
}
|
||||
return Object.fromEntries(headers);
|
||||
},
|
||||
{{/if}}
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { QueryCache, QueryClient } from '@tanstack/react-query';
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
|
||||
import type { AppRouter } from '../../../server/src/routers'; {{! Adjust path if necessary }}
|
||||
import type { AppRouter } from '../../../server/src/routers';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -5,11 +5,22 @@ import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Container } from "@/components/container";
|
||||
import { SignIn } from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { queryClient, orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { queryClient, trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
|
||||
export default function Home() {
|
||||
{{#if (eq api "orpc")}}
|
||||
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
|
||||
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return (
|
||||
@@ -1,5 +1,10 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "trpc")}}
|
||||
import { queryClient } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
import { queryClient } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -85,4 +90,4 @@ export function SignIn() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "trpc")}}
|
||||
import { queryClient } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
import { queryClient } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -1,6 +1,9 @@
|
||||
{{#if (eq orm "prisma")}}
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
{{#if (includes frontend "native")}}
|
||||
import { expo } from "@better-auth/expo";
|
||||
{{/if}}
|
||||
import prisma from "../../prisma";
|
||||
|
||||
export const auth = betterAuth({
|
||||
@@ -9,14 +12,27 @@ export const auth = betterAuth({
|
||||
{{#if (eq database "sqlite")}}provider: "sqlite"{{/if}}
|
||||
{{#if (eq database "mysql")}}provider: "mysql"{{/if}}
|
||||
}),
|
||||
trustedOrigins: [process.env.CORS_ORIGIN || ""],
|
||||
emailAndPassword: { enabled: true }
|
||||
trustedOrigins: [
|
||||
process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
|
||||
"my-better-t-app://",{{/if}}
|
||||
],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
{{~#if (includes frontend "native")}}
|
||||
,
|
||||
plugins: [expo()]
|
||||
{{/if~}}
|
||||
});
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq orm "drizzle")}}
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
{{#if (includes frontend "native")}}
|
||||
import { expo } from "@better-auth/expo";
|
||||
{{/if}}
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema/auth";
|
||||
|
||||
@@ -25,19 +41,42 @@ export const auth = betterAuth({
|
||||
{{#if (eq database "postgres")}}provider: "pg",{{/if}}
|
||||
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
|
||||
{{#if (eq database "mysql")}}provider: "mysql",{{/if}}
|
||||
schema: schema
|
||||
schema: schema,
|
||||
}),
|
||||
trustedOrigins: [process.env.CORS_ORIGIN || ""],
|
||||
emailAndPassword: { enabled: true }
|
||||
trustedOrigins: [
|
||||
process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
|
||||
"my-better-t-app://",{{/if}}
|
||||
],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
{{~#if (includes frontend "native")}}
|
||||
,
|
||||
plugins: [expo()]
|
||||
{{/if~}}
|
||||
});
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq orm "none")}}
|
||||
import { betterAuth } from "better-auth";
|
||||
{{#if (includes frontend "native")}}
|
||||
import { expo } from "@better-auth/expo";
|
||||
{{/if}}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: "",
|
||||
trustedOrigins: [process.env.CORS_ORIGIN || ""],
|
||||
emailAndPassword: { enabled: true }
|
||||
database: "", // Invalid configuration
|
||||
trustedOrigins: [
|
||||
process.env.CORS_ORIGIN || "",{{#if (includes frontend "native")}}
|
||||
"my-better-t-app://", // Use hardcoded scheme{{/if}}
|
||||
],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
{{~#if (includes frontend "native")}}
|
||||
,
|
||||
plugins: [expo()]
|
||||
{{/if~}}
|
||||
});
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
// import { authClient } from "~/lib/auth-client";
|
||||
const {$authClient} = useNuxtApp()
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const emit = defineEmits(['switchToSignUp'])
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function onSubmit (event: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
await $authClient.signIn.email(
|
||||
{
|
||||
email: event.data.email,
|
||||
password: event.data.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({ title: 'Sign in successful' })
|
||||
navigateTo('/dashboard', { replace: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign in failed', description: error.error.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password">
|
||||
<UInput v-model="state.password" type="password" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="loading">
|
||||
Sign In
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<UButton
|
||||
variant="link"
|
||||
@click="$emit('switchToSignUp')"
|
||||
class="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
// import { authClient } from "~/lib/auth-client";
|
||||
const {$authClient} = useNuxtApp()
|
||||
|
||||
const emit = defineEmits(['switchToSignIn'])
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function onSubmit (event: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
await $authClient.signUp.email(
|
||||
{
|
||||
name: event.data.name,
|
||||
email: event.data.email,
|
||||
password: event.data.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({ title: 'Sign up successful' })
|
||||
navigateTo('/dashboard', { replace: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign up failed', description: error.error.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Name" name="name">
|
||||
<UInput v-model="state.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password">
|
||||
<UInput v-model="state.password" type="password" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="loading">
|
||||
Sign Up
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<UButton
|
||||
variant="link"
|
||||
@click="$emit('switchToSignIn')"
|
||||
class="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
apps/cli/templates/auth/web/nuxt/app/components/UserMenu.vue
Normal file
43
apps/cli/templates/auth/web/nuxt/app/components/UserMenu.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
// import { authClient } from "~/lib/auth-client";
|
||||
const {$authClient} = useNuxtApp()
|
||||
const session = $authClient.useSession()
|
||||
const toast = useToast()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await $authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: async () => {
|
||||
toast.add({ title: 'Signed out successfully' })
|
||||
await navigateTo('/', { replace: true, external: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign out failed', description: error?.error?.message || 'Unknown error'})
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred during sign out', description: error.message || 'Please try again.'})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<USkeleton v-if="session.isPending" class="h-9 w-24" />
|
||||
|
||||
<UButton v-else-if="!session.data" variant="outline" to="/login">
|
||||
Sign In
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-else
|
||||
variant="solid"
|
||||
icon="i-lucide-log-out"
|
||||
label="Sign out"
|
||||
@click="handleSignOut()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
12
apps/cli/templates/auth/web/nuxt/app/middleware/auth.ts
Normal file
12
apps/cli/templates/auth/web/nuxt/app/middleware/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
if (import.meta.server) return
|
||||
|
||||
const { $authClient } = useNuxtApp()
|
||||
const session = $authClient.useSession()
|
||||
|
||||
if (session.value.isPending || !session.value) {
|
||||
if (to.path === "/dashboard") {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
}
|
||||
});
|
||||
27
apps/cli/templates/auth/web/nuxt/app/pages/dashboard.vue
Normal file
27
apps/cli/templates/auth/web/nuxt/app/pages/dashboard.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
const {$authClient} = useNuxtApp()
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth']
|
||||
})
|
||||
|
||||
const { $orpc } = useNuxtApp()
|
||||
|
||||
const session = $authClient.useSession()
|
||||
|
||||
const privateData = useQuery($orpc.privateData.queryOptions())
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<div v-if="session?.data?.user">
|
||||
<p class="mb-2">Welcome {{ session.data.user.name }}</p>
|
||||
</div>
|
||||
<div v-if="privateData.status.value === 'pending'">Loading private data...</div>
|
||||
<div v-else-if="privateData.status.value === 'error'">Error loading private data: {{ privateData.error.value?.message }}</div>
|
||||
<p v-else-if="privateData.data.value">Private Data: {{ privateData.data.value.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
24
apps/cli/templates/auth/web/nuxt/app/pages/login.vue
Normal file
24
apps/cli/templates/auth/web/nuxt/app/pages/login.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const { $authClient } = useNuxtApp();
|
||||
import SignInForm from "~/components/SignInForm.vue";
|
||||
import SignUpForm from "~/components/SignUpForm.vue";
|
||||
|
||||
const session = $authClient.useSession();
|
||||
const showSignIn = ref(true);
|
||||
|
||||
watchEffect(() => {
|
||||
if (!session?.value.isPending && session?.value.data) {
|
||||
navigateTo("/dashboard", { replace: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Loader v-if="session.isPending" />
|
||||
<div v-else-if="!session.data">
|
||||
<SignInForm v-if="showSignIn" @switch-to-sign-up="showSignIn = false" />
|
||||
<SignUpForm v-else @switch-to-sign-in="showSignIn = true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
apps/cli/templates/auth/web/nuxt/app/plugins/auth-client.ts
Normal file
16
apps/cli/templates/auth/web/nuxt/app/plugins/auth-client.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createAuthClient } from "better-auth/vue";
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
const config = useRuntimeConfig()
|
||||
const serverUrl = config.public.serverURL
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: serverUrl
|
||||
})
|
||||
|
||||
return {
|
||||
provide: {
|
||||
authClient: authClient
|
||||
}
|
||||
}
|
||||
})
|
||||
64
apps/cli/templates/examples/ai/web/nuxt/app/pages/ai.vue
Normal file
64
apps/cli/templates/examples/ai/web/nuxt/app/pages/ai.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useChat } from '@ai-sdk/vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const serverUrl = config.public.serverURL
|
||||
|
||||
const { messages, input, handleSubmit } = useChat({
|
||||
api: `${serverUrl}/ai`,
|
||||
})
|
||||
|
||||
const messagesEndRef = ref<null | HTMLDivElement>(null)
|
||||
|
||||
watch(messages, async () => {
|
||||
await nextTick()
|
||||
messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
|
||||
// Helper: Concatenate all text parts for a message
|
||||
function getMessageText(message: any) {
|
||||
return message.parts
|
||||
.filter((part: any) => part.type === 'text')
|
||||
.map((part: any) => part.text)
|
||||
.join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
|
||||
<div class="overflow-y-auto space-y-4 pb-4">
|
||||
<div v-if="messages.length === 0" class="text-center text-muted-foreground mt-8">
|
||||
Ask me anything to get started!
|
||||
</div>
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="[
|
||||
'p-3 rounded-lg',
|
||||
message.role === 'user' ? 'bg-primary/10 ml-8' : 'bg-secondary/20 mr-8'
|
||||
]"
|
||||
>
|
||||
<p class="text-sm font-semibold mb-1">
|
||||
{{ message.role === 'user' ? 'You' : 'AI Assistant' }}
|
||||
</p>
|
||||
<div class="whitespace-pre-wrap">{{ getMessageText(message) }}</div>
|
||||
</div>
|
||||
<div ref="messagesEndRef" />
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="w-full flex items-center space-x-2 pt-2 border-t">
|
||||
<UInput
|
||||
name="prompt"
|
||||
v-model="input"
|
||||
placeholder="Type your message..."
|
||||
class="flex-1"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<UButton type="submit" color="primary" size="md" square>
|
||||
<UIcon name="i-lucide-send" class="w-5 h-5" />
|
||||
</UButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
108
apps/cli/templates/examples/todo/web/nuxt/app/pages/todos.vue
Normal file
108
apps/cli/templates/examples/todo/web/nuxt/app/pages/todos.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const { $orpc } = useNuxtApp()
|
||||
|
||||
const newTodoText = ref('')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const todos = useQuery($orpc.todo.getAll.queryOptions())
|
||||
|
||||
const createMutation = useMutation($orpc.todo.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries()
|
||||
newTodoText.value = ''
|
||||
}
|
||||
}))
|
||||
|
||||
const toggleMutation = useMutation($orpc.todo.toggle.mutationOptions({
|
||||
onSuccess: () => queryClient.invalidateQueries()
|
||||
}))
|
||||
|
||||
const deleteMutation = useMutation($orpc.todo.delete.mutationOptions({
|
||||
onSuccess: () => queryClient.invalidateQueries()
|
||||
}))
|
||||
|
||||
function handleAddTodo() {
|
||||
if (newTodoText.value.trim()) {
|
||||
createMutation.mutate({ text: newTodoText.value })
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTodo(id: number, completed: boolean) {
|
||||
toggleMutation.mutate({ id, completed: !completed })
|
||||
}
|
||||
|
||||
function handleDeleteTodo(id: number) {
|
||||
deleteMutation.mutate({ id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-md py-10">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div>
|
||||
<div class="text-xl font-bold">Todo List</div>
|
||||
<div class="text-muted text-sm">Manage your tasks efficiently</div>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="handleAddTodo" class="mb-6 flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="newTodoText"
|
||||
placeholder="Add a new task..."
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-plus"
|
||||
>
|
||||
Add
|
||||
</UButton>
|
||||
</form>
|
||||
|
||||
<div v-if="todos.status.value === 'pending'" class="flex justify-center py-4">
|
||||
<UIcon name="i-lucide-loader-2" class="animate-spin w-6 h-6" />
|
||||
</div>
|
||||
<p v-else-if="todos.status.value === 'error'" class="py-4 text-center text-red-500">
|
||||
Error: {{ todos.error.value?.message || 'Failed to load todos' }}
|
||||
</p>
|
||||
<p v-else-if="todos.data.value?.length === 0" class="py-4 text-center">
|
||||
No todos yet. Add one above!
|
||||
</p>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="todo in todos.data.value"
|
||||
:key="todo.id"
|
||||
class="flex items-center justify-between rounded-md border p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox
|
||||
:model-value="todo.completed"
|
||||
@update:model-value="() => handleToggleTodo(todo.id, todo.completed)"
|
||||
:id="`todo-${todo.id}`"
|
||||
/>
|
||||
<label
|
||||
:for="`todo-${todo.id}`"
|
||||
:class="{ 'line-through text-muted': todo.completed }"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ todo.text }}
|
||||
</label>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
square
|
||||
@click="handleDeleteTodo(todo.id)"
|
||||
aria-label="Delete todo"
|
||||
icon="i-lucide-trash-2"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
node-linker=hoisted
|
||||
5
apps/cli/templates/extras/_npmrc.hbs
Normal file
5
apps/cli/templates/extras/_npmrc.hbs
Normal file
@@ -0,0 +1,5 @@
|
||||
node-linker=hoisted
|
||||
{{#if (includes frontend "nuxt")}}
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
{{/if}}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { Container } from "@/components/container";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
|
||||
export default function Home() {
|
||||
{{#if (eq api "orpc")}}
|
||||
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "../global.css";
|
||||
{{#if (eq api "trpc")}}
|
||||
import { queryClient } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
import { queryClient } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
import { NAV_THEME } from "@/lib/constants";
|
||||
import React, { useRef } from "react";
|
||||
import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
@@ -56,12 +61,12 @@ export default function RootLayout() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
|
||||
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GestureHandlerRootView style=\{{ flex: 1 }}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ title: "Modal", presentation: "modal" }}
|
||||
options=\{{ title: "Modal", presentation: "modal" }}
|
||||
/>
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
@@ -16,9 +16,6 @@
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@tanstack/react-query": "^5.69.2",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@trpc/tanstack-react-query": "^11.0.0",
|
||||
"expo": "^52.0.44",
|
||||
"expo-constants": "~17.0.8",
|
||||
"expo-linking": "~7.0.5",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { AppRouter } from "../../server/src/routers";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: trpcClient,
|
||||
queryClient,
|
||||
});
|
||||
24
apps/cli/templates/frontend/nuxt/_gitignore
Normal file
24
apps/cli/templates/frontend/nuxt/_gitignore
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
15
apps/cli/templates/frontend/nuxt/app/app.config.ts
Normal file
15
apps/cli/templates/frontend/nuxt/app/app.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default defineAppConfig({
|
||||
// https://ui.nuxt.com/getting-started/theme#design-system
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'emerald',
|
||||
neutral: 'slate',
|
||||
},
|
||||
button: {
|
||||
defaultVariants: {
|
||||
// Set default button color to neutral
|
||||
// color: 'neutral'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
13
apps/cli/templates/frontend/nuxt/app/app.vue
Normal file
13
apps/cli/templates/frontend/nuxt/app/app.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLoadingIndicator />
|
||||
<UApp>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
<VueQueryDevtools />
|
||||
</template>
|
||||
2
apps/cli/templates/frontend/nuxt/app/assets/css/main.css
Normal file
2
apps/cli/templates/frontend/nuxt/app/assets/css/main.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { USeparator } from '#components';
|
||||
import ModeToggle from './ModeToggle.vue'
|
||||
{{#if auth}}
|
||||
import UserMenu from './UserMenu.vue'
|
||||
{{/if}}
|
||||
|
||||
const links = [
|
||||
{ to: "/", label: "Home" },
|
||||
{{#if auth}}
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
{{/if}}
|
||||
{{#if (includes examples "todo")}}
|
||||
{ to: "/todos", label: "Todos" },
|
||||
{{/if}}
|
||||
{{#if (includes examples "ai")}}
|
||||
{ to: "/ai", label: "AI Chat" },
|
||||
{{/if}}
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-row items-center justify-between px-2 py-1">
|
||||
<nav class="flex gap-4 text-lg">
|
||||
<NuxtLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="text-foreground hover:text-primary"
|
||||
active-class="text-primary font-semibold"
|
||||
>
|
||||
\{{ link.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
{{#if auth}}
|
||||
<UserMenu />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<USeparator />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center pt-8">
|
||||
<UIcon name="i-lucide-loader-2" class="animate-spin text-2xl" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const isDark = computed({
|
||||
get () {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set (value) {
|
||||
colorMode.preference = value ? 'dark' : 'light'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<USwitch
|
||||
v-model="isDark"
|
||||
:checked-icon="isDark ? 'i-lucide-moon' : ''"
|
||||
:unchecked-icon="!isDark ? 'i-lucide-sun' : ''"
|
||||
class="mr-2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
11
apps/cli/templates/frontend/nuxt/app/layouts/default.vue
Normal file
11
apps/cli/templates/frontend/nuxt/app/layouts/default.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-rows-[auto_1fr] h-full">
|
||||
<Header />
|
||||
<main class="overflow-y-auto">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
63
apps/cli/templates/frontend/nuxt/app/pages/index.vue
Normal file
63
apps/cli/templates/frontend/nuxt/app/pages/index.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const { $orpc } = useNuxtApp()
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
const TITLE_TEXT = `
|
||||
██████╗ ███████╗████████╗████████╗███████╗██████╗
|
||||
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
|
||||
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝
|
||||
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗
|
||||
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║
|
||||
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||
|
||||
████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗
|
||||
╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
|
||||
██║ ███████╗ ██║ ███████║██║ █████╔╝
|
||||
██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗
|
||||
██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗
|
||||
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
||||
`;
|
||||
|
||||
const healthCheck = useQuery($orpc.healthCheck.queryOptions())
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto max-w-3xl px-4 py-2">
|
||||
<pre class="overflow-x-auto font-mono text-sm whitespace-pre-wrap">{{ TITLE_TEXT }}</pre>
|
||||
<div class="grid gap-6 mt-4">
|
||||
<section class="rounded-lg border p-4">
|
||||
<h2 class="mb-2 font-medium">API Status</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-500 animate-pulse': healthCheck.status.value === 'pending',
|
||||
'bg-green-500': healthCheck.status.value === 'success',
|
||||
'bg-red-500': healthCheck.status.value === 'error',
|
||||
'bg-gray-400': healthCheck.status.value !== 'pending' &&
|
||||
healthCheck.status.value !== 'success' &&
|
||||
healthCheck.status.value !== 'error'
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<template v-if="healthCheck.status.value === 'pending'">
|
||||
Checking...
|
||||
</template>
|
||||
<template v-else-if="healthCheck.status.value === 'success'">
|
||||
Connected ({{ healthCheck.data.value }})
|
||||
</template>
|
||||
<template v-else-if="healthCheck.status.value === 'error'">
|
||||
Error: {{ healthCheck.error.value?.message || 'Failed to connect' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Idle
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
apps/cli/templates/frontend/nuxt/app/plugins/vue-query.ts
Normal file
44
apps/cli/templates/frontend/nuxt/app/plugins/vue-query.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
DehydratedState,
|
||||
VueQueryPluginOptions,
|
||||
} from '@tanstack/vue-query'
|
||||
import {
|
||||
dehydrate,
|
||||
hydrate,
|
||||
QueryCache,
|
||||
QueryClient,
|
||||
VueQueryPlugin,
|
||||
} from '@tanstack/vue-query'
|
||||
|
||||
export default defineNuxtPlugin((nuxt) => {
|
||||
const vueQueryState = useState<DehydratedState | null>('vue-query')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
console.log(error)
|
||||
toast.add({
|
||||
title: 'Error',
|
||||
description: error?.message || 'An unexpected error occurred.',
|
||||
})
|
||||
},
|
||||
}),
|
||||
})
|
||||
const options: VueQueryPluginOptions = { queryClient }
|
||||
|
||||
nuxt.vueApp.use(VueQueryPlugin, options)
|
||||
|
||||
if (import.meta.server) {
|
||||
nuxt.hooks.hook('app:rendered', () => {
|
||||
vueQueryState.value = dehydrate(queryClient)
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
nuxt.hooks.hook('app:created', () => {
|
||||
hydrate(queryClient, vueQueryState.value)
|
||||
})
|
||||
}
|
||||
})
|
||||
19
apps/cli/templates/frontend/nuxt/nuxt.config.ts.hbs
Normal file
19
apps/cli/templates/frontend/nuxt/nuxt.config.ts.hbs
Normal file
@@ -0,0 +1,19 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2024-11-01',
|
||||
future: {
|
||||
compatibilityVersion: 4
|
||||
},
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/ui'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
devServer: {
|
||||
port: 3001
|
||||
},
|
||||
ssr: false,
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
serverURL: process.env.NUXT_PUBLIC_SERVER_URL,
|
||||
}
|
||||
}
|
||||
})
|
||||
25
apps/cli/templates/frontend/nuxt/package.json
Normal file
25
apps/cli/templates/frontend/nuxt/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "3.0.2",
|
||||
"@tanstack/vue-query": "^5.74.5",
|
||||
"nuxt": "^3.16.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/vue-query-devtools": "^5.74.5",
|
||||
"@iconify-json/lucide": "^1.2.38"
|
||||
}
|
||||
}
|
||||
BIN
apps/cli/templates/frontend/nuxt/public/favicon.ico
Normal file
BIN
apps/cli/templates/frontend/nuxt/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
apps/cli/templates/frontend/nuxt/public/robots.txt
Normal file
2
apps/cli/templates/frontend/nuxt/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
3
apps/cli/templates/frontend/nuxt/server/tsconfig.json
Normal file
3
apps/cli/templates/frontend/nuxt/server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
4
apps/cli/templates/frontend/nuxt/tsconfig.json
Normal file
4
apps/cli/templates/frontend/nuxt/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user