работаем бля работаем

This commit is contained in:
2026-05-09 03:21:44 +06:00
parent f845777bac
commit 0b148c6a7d
169 changed files with 15816 additions and 1005 deletions

View File

@@ -0,0 +1,20 @@
<template>
<Component :is="layoutComponent">
<RouterView />
</Component>
</template>
<script setup lang="ts">
import DefaultLayout from '@shared/layouts/Default.vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const layoutComponent = computed(() => {
return route.meta.layout ?? DefaultLayout
})
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="error-app">
<img class="error-app__image" src="/sad-pepe.png" alt="Oops!" draggable="false">
<p class="error-app__message">
{{ message }}
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
message: string
}>()
</script>
<style lang="scss">
.error-app {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
&__image {
width: 120px;
margin-bottom: 32px;
user-select: none;
}
&__message {
color: var(--text-muted);
}
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<div class="preloader-app">
<p>
Loading...
</p>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss">
.preloader-app {
display: flex;
height: 100%;
> p {
margin: auto;
padding: 8px 16px;
border-radius: 16px;
background-color: var(--bg-light);
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="updater-app">
<p v-if="checking">
Checking updates...
</p>
<p v-else-if="!!lastUpdate">
Update available: {{ lastUpdate.version }}
</p>
</div>
</template>
<script setup lang="ts">
import { useUpdater } from '@shared/composables/use-updater'
const { checking, lastUpdate } = useUpdater()
</script>
<style lang="scss">
.updater-app {
display: flex;
height: 100%;
> p {
margin: auto;
padding: 8px 16px;
border-radius: 16px;
background-color: var(--bg-light);
}
}
</style>

View File

@@ -0,0 +1,21 @@
import { useAuth } from '@shared/composables/use-auth'
import { createApp } from 'vue'
import App from '../App.vue'
import routerPlugin, { router } from '../plugins/router'
const mountPoint = '#app'
export default async function () {
const { authorized } = useAuth()
const app = createApp(App)
app.use(routerPlugin)
await router.isReady()
if (!authorized.value && router.currentRoute.value.meta.auth === undefined) {
router.push('/auth/login')
}
app.mount(mountPoint)
}

View File

@@ -0,0 +1,17 @@
import api from '@shared/api/client'
import { useAuth } from '@shared/composables/use-auth'
export default async function () {
const { setMe } = useAuth()
try {
const response = await api.chad.authMe()
setMe(response.data)
}
catch (error) {
if (error.error?.statusCode !== 401) {
throw new Error('Authorization failed')
}
}
}

View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import Error from '../Error.vue'
const mountPoint = '#app'
export default async function (message: string) {
const app = createApp(Error, { message })
app.mount(mountPoint)
}

View File

@@ -0,0 +1,19 @@
import type { App } from 'vue'
import { createApp } from 'vue'
import Preloader from '../Preloader.vue'
const mountPoint = '#preloader'
let preloaderApp: App | undefined
export default {
show() {
preloaderApp = createApp(Preloader)
preloaderApp.mount(mountPoint)
},
hide() {
preloaderApp?.unmount()
preloaderApp = undefined
},
}

View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { useUpdater } from '@/shared/composables/use-updater'
import Updater from '../Updater.vue'
const mountPoint = '#updater'
export default async function () {
const { lastUpdate, checkForUpdates } = useUpdater()
const updater = createApp(Updater)
updater.mount(mountPoint)
await checkForUpdates()
if (!lastUpdate.value) {
updater.unmount()
return
}
await lastUpdate.value.downloadAndInstall()
}

View File

@@ -0,0 +1,30 @@
import initializeApp from './bootstrap/app'
import authorize from './bootstrap/authorize'
import showError from './bootstrap/error'
import preloader from './bootstrap/preloader'
import checkUpdates from './bootstrap/updater'
(async () => {
try {
await checkUpdates()
preloader.show()
await authorize()
initializeApp()
}
catch (error) {
console.error(error)
if (error instanceof Error && error.message) {
showError(error.message)
}
else {
showError('Something went wrong')
}
}
finally {
preloader.hide()
}
})()

View File

@@ -0,0 +1,11 @@
import type { PrimeVueConfiguration } from 'primevue/config'
import type { FunctionPlugin, Plugin } from 'vue'
import PrimeVue from 'primevue/config'
export default {
install(app) {
app.use(PrimeVue as Plugin<PrimeVueConfiguration>, {
unstyled: true,
})
},
} as FunctionPlugin

View File

@@ -0,0 +1,21 @@
import type { Component, FunctionPlugin } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
declare module 'vue-router' {
interface RouteMeta {
layout?: Component
auth?: false | 'guest'
}
}
export const router = createRouter({
history: createWebHistory(),
routes,
})
export default {
install(app) {
app.use(router)
},
} as FunctionPlugin

View File

@@ -0,0 +1,14 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import AuthLayout from '@shared/layouts/Auth.vue'
definePage({
meta: {
auth: 'guest',
layout: AuthLayout,
},
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="login-page">
<form class="login-page__form" @submit.prevent="">
<div class="login-page__fields">
<ChadInput placeholder="Login" error="Jopa" />
<ChadInput placeholder="Password" />
<ChadPasswordInput placeholder="Password" label="Test" />
</div>
<ChadButton class="login-page__submit" full type="submit">
Let's go
</ChadButton>
</form>
</div>
</template>
<script setup lang="ts">
import ChadButton from '@shared/components/ui/Button.vue'
import ChadInput from '@shared/components/ui/Input.vue'
import ChadPasswordInput from '@shared/components/ui/PasswordInput.vue'
</script>
<style lang="scss">
.login-page {
&__fields {
> *:not(:last-child) {
margin-bottom: 16px;
}
}
&__submit {
margin-top: 24px;
}
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
Register page
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,5 @@
<template>
Index page
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,11 @@
import { Api } from './generated-chad-api'
const api = new Api({
baseUrl: 'http://localhost:4000',
})
function isChadResponseError(error) {
}
export default api

View File

@@ -0,0 +1,755 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
/**
* Attachment
* Attachment
*/
export interface Attachment {
id: string;
name: string;
mimetype: string;
/** @min 0 */
size: number;
/** @format date-time */
createdAt: string;
}
/**
* Channel
* Channel
*/
export interface Channel {
id: string;
ownerId: string | null;
name: string;
persistent: boolean;
}
/**
* ChatMessage
* ChatMessage
*/
export interface ChatMessage {
/** @format uuid */
id: string;
/** @format uuid */
senderId: string;
/** @minLength 1 */
text: string;
/** @format date-time */
createdAt: string;
/** @format date-time */
updatedAt: string;
attachments: string[];
}
/**
* CreateChannelPayload
* CreateChannelPayload
*/
export interface CreateChannelPayload {
name: string;
persistent: boolean;
}
/**
* CreateUser
* CreateUser
*/
export interface CreateUser {
/** @minLength 1 */
username: string;
/** @minLength 6 */
password: string;
}
/**
* GetAttachmentParams
* GetAttachmentParams
*/
export interface GetAttachmentParams {
/** @format uuid */
id: string;
}
/**
* GetUserQuery
* GetUserQuery
*/
export interface GetUserQuery {
username?: string;
}
/**
* Login
* Login
*/
export interface Login {
/** @minLength 1 */
username: string;
/** @minLength 1 */
password: string;
}
/**
* NewChatMessagePayload
* NewChatMessagePayload
*/
export interface NewChatMessagePayload {
/** @minLength 1 */
text: string;
attachments?: string[];
}
/**
* Reply
* Reply
*/
export interface Reply {
/** @format uuid */
messageId: string;
/** @format uuid */
senderId: string;
text: string;
}
/**
* ResponseError
* ResponseError
*/
export interface ResponseError {
statusCode: number;
error: string;
message: string;
}
/**
* UpdateUserPayload
* UpdateUserPayload
*/
export interface UpdateUserPayload {
displayName: string;
}
/**
* UpdateUserPreferencesPayload
* UpdateUserPreferencesPayload
*/
export interface UpdateUserPreferencesPayload {
toggleInputHotkey?: string;
toggleOutputHotkey?: string;
}
/**
* UserPreferences
* UserPreferences
*/
export interface UserPreferences {
toggleInputHotkey: string;
toggleOutputHotkey: string;
}
/**
* User
* User
*/
export interface User {
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
}
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (
securityData: SecurityDataType | null,
) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) =>
fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(
params1: RequestParams,
params2?: RequestParams,
): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData
? { "Content-Type": type }
: {}),
},
signal:
(cancelToken
? this.createAbortSignal(cancelToken)
: requestParams.signal) || null,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body),
},
).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const responseToParse = responseFormat ? response.clone() : response;
const data = !responseFormat
? r
: await responseToParse[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}
/**
* @title Chad API
* @version 1.0.0
*/
export class Api<
SecurityDataType extends unknown,
> extends HttpClient<SecurityDataType> {
chad = {
/**
* @description Pass file to multipart/form-data
*
* @tags Attachment
* @name AttachmentUpload
* @summary Upload attachment
* @request POST:/chad/attachment/upload
*/
attachmentUpload: (params: RequestParams = {}) =>
this.request<string, ResponseError>({
path: `/chad/attachment/upload`,
method: "POST",
format: "json",
...params,
}),
/**
* No description
*
* @tags Attachment
* @name AttachmentGet
* @summary Get attachment
* @request GET:/chad/attachment/{id}
*/
attachmentGet: (id: string, params: RequestParams = {}) =>
this.request<any, ResponseError>({
path: `/chad/attachment/${id}`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthRegister
* @summary Register
* @request POST:/chad/auth/register
*/
authRegister: (data: CreateUser, params: RequestParams = {}) =>
this.request<User, ResponseError>({
path: `/chad/auth/register`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthLogin
* @summary Login
* @request POST:/chad/auth/login
*/
authLogin: (data: Login, params: RequestParams = {}) =>
this.request<User, ResponseError>({
path: `/chad/auth/login`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthMe
* @summary Me
* @request GET:/chad/auth/me
*/
authMe: (params: RequestParams = {}) =>
this.request<User, ResponseError>({
path: `/chad/auth/me`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthLogout
* @summary Logout
* @request POST:/chad/auth/logout
*/
authLogout: (params: RequestParams = {}) =>
this.request<any, ResponseError>({
path: `/chad/auth/logout`,
method: "POST",
...params,
}),
/**
* No description
*
* @tags Channel
* @name ChannelList
* @summary Get channel list
* @request GET:/chad/channels
*/
channelList: (params: RequestParams = {}) =>
this.request<Channel[], ResponseError>({
path: `/chad/channels`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags Channel
* @name ChannelCreate
* @summary Create channel
* @request POST:/chad/channels
*/
channelCreate: (data: CreateChannelPayload, params: RequestParams = {}) =>
this.request<Channel, ResponseError>({
path: `/chad/channels`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Channel
* @name ChannelDelete
* @summary Delete channel
* @request DELETE:/chad/channels/{id}
*/
channelDelete: (id: string, params: RequestParams = {}) =>
this.request<any, ResponseError>({
path: `/chad/channels/${id}`,
method: "DELETE",
...params,
}),
/**
* No description
*
* @tags Chat
* @name ChatSend
* @summary Send message
* @request POST:/chad/chat/send
*/
chatSend: (
data: {
/** @minLength 1 */
text: string;
attachments?: string[];
},
params: RequestParams = {},
) =>
this.request<
{
/** @format uuid */
id: string;
/** @format uuid */
senderId: string;
/** @minLength 1 */
text: string;
/** @format date-time */
createdAt: string;
/** @format date-time */
updatedAt: string;
attachments: string[];
},
ResponseError
>({
path: `/chad/chat/send`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Chat
* @name ChatMessages
* @summary Get messages
* @request GET:/chad/chat
*/
chatMessages: (
query: {
/**
* Cursor to message
* @format uuid
*/
cursor?: string;
/**
* @min 1
* @max 100
* @default 10
*/
limit: number;
},
params: RequestParams = {},
) =>
this.request<
{
messages: {
/** @format uuid */
id: string;
/** @format uuid */
senderId: string;
/** @minLength 1 */
text: string;
/** @format date-time */
createdAt: string;
/** @format date-time */
updatedAt: string;
attachments: string[];
}[];
/**
* Cursor to last message
* @format uuid
*/
nextCursor?: string;
},
ResponseError
>({
path: `/chad/chat`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserGet
* @summary Get user
* @request GET:/chad/user
*/
userGet: (
query?: {
username?: string;
},
params: RequestParams = {},
) =>
this.request<User, ResponseError>({
path: `/chad/user`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserGetPreferences
* @summary Get preferences
* @request GET:/chad/user/preferences
*/
userGetPreferences: (params: RequestParams = {}) =>
this.request<UserPreferences, ResponseError>({
path: `/chad/user/preferences`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserUpdatePreferences
* @summary Update preferences
* @request PATCH:/chad/user/preferences
*/
userUpdatePreferences: (
data: UpdateUserPreferencesPayload,
params: RequestParams = {},
) =>
this.request<any, ResponseError>({
path: `/chad/user/preferences`,
method: "PATCH",
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags User
* @name UserUpdateProfile
* @summary Update profile
* @request PATCH:/chad/profile
*/
userUpdateProfile: (data: UpdateUserPayload, params: RequestParams = {}) =>
this.request<User, ResponseError>({
path: `/chad/profile`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
};
}

View File

@@ -0,0 +1,31 @@
<template>
<div class="app-logo">
<p class="app-logo__title">
Chad
</p>
<p class="app-logo__version">
{{ version }}
</p>
</div>
</template>
<script setup lang="ts">
import { useApp } from '@shared/composables/use-app'
const { version } = useApp()
</script>
<style lang="scss">
.app-logo {
&__title {
color: var(--primary);
font-weight: 700;
line-height: 20px;
}
&__version {
font-size: 0.75rem;
color: var(--text-muted);
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<button
class="chad-button"
:data-loading="loading || undefined"
:disabled="disabled"
:type="type"
:data-full="full || undefined"
>
<slot />
</button>
</template>
<script setup lang="ts">
defineOptions({
name: 'ChadButton',
})
withDefaults(
defineProps<Props>(),
{
type: 'button',
},
)
interface Props {
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit'
full?: boolean
}
</script>
<style lang="scss">
.chad-button {
border: none;
color: var(--bg-dark);
border-radius: 12px;
padding-inline: 12px;
height: 44px;
background-color: var(--primary);
font-weight: 700;
text-align: center;
cursor: pointer;
transition-property: color, background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
outline: none;
&[data-full] {
width: 100%;
}
&:hover,
&:focus,
&:active {
background-color: var(--secondary);
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<FieldRoot class="chad-input" :invalid="!!error">
<FieldLabel v-if="label" class="chad-input__label">
{{ label }}
</FieldLabel>
<div class="chad-input__control">
<FieldInput v-model="modelValue" class="chad-input__input" :placeholder="placeholder" type="text" />
</div>
<FieldHelperText v-if="helper" class="chad-input__helper">
{{ helper }}
</FieldHelperText>
<FieldErrorText v-if="error" class="chad-input__error">
{{ error }}
</FieldErrorText>
</FieldRoot>
</template>
<script setup lang="ts">
import type { FieldInputProps, FieldRootProps } from '@ark-ui/vue/field'
import { FieldErrorText, FieldHelperText, FieldInput, FieldLabel, FieldRoot } from '@ark-ui/vue/field'
defineOptions({
name: 'ChadInput',
})
defineProps<Props>()
const modelValue = defineModel<FieldInputProps['modelValue']>('modelValue')
interface Props extends Omit<FieldRootProps, 'ids' | 'invalid'> {
label?: string
placeholder?: string
helper?: string
error?: string
}
</script>
<style lang="scss">
.chad-input {
$self: &;
&__label {
display: block;
color: var(--text-muted);
margin-bottom: 6px;
font-size: 0.75rem;
}
&__control {
border-radius: 12px;
padding-inline: 12px;
height: 44px;
background-color: var(--bg-light);
border: none;
outline: 1px solid var(--border);
outline-offset: -1px;
font-size: 1rem;
&:hover,
&:focus-within {
outline-color: var(--primary);
}
#{$self}[data-invalid] & {
outline-color: var(--danger);
}
}
&__input {
width: 100%;
height: 100%;
background: none;
border: none;
outline: none;
color: var(--text);
caret-color: var(--secondary);
&::placeholder {
color: var(--text-muted);
opacity: 0.4;
}
&:-webkit-autofill,
&:-webkit-autofill:enabled,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
-webkit-box-shadow: 0 0 0px 1000px var(--bg) inset;
box-shadow: 0 0 0px 1000px var(--bg) inset;
background-color: var(--bg);
}
}
&__helper,
&__error {
display: block;
font-size: 0.75rem;
margin-top: 6px;
}
&__helper {
color: var(--text-muted);
}
&__error {
color: var(--danger);
}
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<FieldRoot class="chad-password-input" :invalid="!!error">
<FieldLabel v-if="label" class="chad-password-input__label">
{{ label }}
</FieldLabel>
<PasswordInputRoot>
<PasswordInputControl class="chad-password-input__control">
<PasswordInputInput v-model="modelValue" class="chad-password-input__input" :placeholder="placeholder" />
<PasswordInputVisibilityTrigger class="chad-password-input__visibility-trigger">
<PasswordInputIndicator>
Z
<template #fallback>
X
</template>
</PasswordInputIndicator>
</PasswordInputVisibilityTrigger>
</PasswordInputControl>
</PasswordInputRoot>
<FieldHelperText v-if="helper" class="chad-password-input__helper">
{{ helper }}
</FieldHelperText>
<FieldErrorText v-if="error" class="chad-password-input__error">
{{ error }}
</FieldErrorText>
</FieldRoot>
</template>
<script setup lang="ts">
import type { FieldInputProps, FieldRootProps } from '@ark-ui/vue/field'
import { FieldErrorText, FieldHelperText, FieldLabel, FieldRoot } from '@ark-ui/vue/field'
import { PasswordInputControl, PasswordInputIndicator, PasswordInputInput, PasswordInputRoot, PasswordInputVisibilityTrigger } from '@ark-ui/vue/password-input'
defineOptions({
name: 'ChadPasswordInput',
})
defineProps<Props>()
const modelValue = defineModel<FieldInputProps['modelValue']>('modelValue')
interface Props extends Omit<FieldRootProps, 'ids' | 'invalid'> {
label?: string
placeholder?: string
helper?: string
error?: string
}
</script>
<style lang="scss">
.chad-password-input {
&__label {
display: block;
color: var(--text-muted);
margin-bottom: 6px;
font-size: 0.75rem;
}
&__control {
position: relative;
border-radius: 12px;
padding-inline: 12px;
height: 44px;
width: 100%;
background-color: var(--bg-light);
border: none;
outline: 1px solid var(--border);
outline-offset: -1px;
color: var(--text);
caret-color: var(--secondary);
font-size: 1rem;
&:hover,
&:focus {
outline-color: var(--primary);
}
&::placeholder {
color: var(--text-muted);
opacity: 0.4;
}
&[data-invalid] {
outline-color: var(--danger);
}
&:-webkit-autofill,
&:-webkit-autofill:enabled,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
-webkit-box-shadow: 0 0 0px 1000px var(--bg) inset;
box-shadow: 0 0 0px 1000px var(--bg) inset;
background-color: var(--bg);
}
}
&__input {
width: 100%;
height: 100%;
background: none;
border: none;
outline: none;
}
&__visibility-trigger {
position: absolute;
}
&__helper,
&__error {
display: block;
font-size: 0.75rem;
margin-top: 6px;
}
&__helper {
color: var(--text-muted);
}
&__error {
color: var(--danger);
}
}
</style>

View File

@@ -0,0 +1,25 @@
import { getVersion as getTauriVersion } from '@tauri-apps/api/app'
import { computedAsync } from '@vueuse/core'
export function useApp() {
const isTauri = '__TAURI_INTERNALS__' in window
const commitSha = __COMMIT_SHA__
const version = computedAsync(() => {
if (import.meta.dev) {
return 'dev'
}
else if (isTauri) {
return getTauriVersion()
}
else {
return 'web'
}
}, '-')
return {
isTauri,
commitSha,
version,
}
}

View File

@@ -0,0 +1,58 @@
import type { User } from '../api/generated-chad-api'
import { createGlobalState } from '@vueuse/core'
import { computed, readonly, shallowRef } from 'vue'
import api from '../api/client'
export const useAuth = createGlobalState(() => {
const me = shallowRef<User>()
const authorized = computed(() => !!me.value)
function setMe(value: User | undefined): void {
me.value = value
}
async function login(username: string, password: string): Promise<void> {
try {
const response = await api.chad.authLogin({
username,
password,
})
setMe(response.data)
}
catch {
setMe(undefined)
}
}
async function register(username: string, password: string): Promise<void> {
try {
const response = await api.chad.authRegister({
username,
password,
})
setMe(response.data)
}
catch {}
}
async function logout(): Promise<void> {
try {
await api.chad.authLogout()
setMe(undefined)
}
catch {}
}
return {
authorized,
me: readonly(me),
setMe,
login,
register,
logout,
}
})

View File

@@ -0,0 +1,38 @@
import type { Update } from '@tauri-apps/plugin-updater'
import { check } from '@tauri-apps/plugin-updater'
import { createGlobalState } from '@vueuse/core'
import { ref, shallowRef } from 'vue'
import { useApp } from './use-app'
export const useUpdater = createGlobalState(() => {
const { isTauri } = useApp()
const lastUpdate = shallowRef<Update>()
const checking = ref(false)
async function checkForUpdates() {
if (!isTauri)
return
try {
checking.value = true
await new Promise(resolve => setTimeout(resolve, 500))
lastUpdate.value = (await check()) ?? undefined
}
catch { }
finally {
checking.value = false
}
return lastUpdate.value
}
return {
lastUpdate,
checking,
checkForUpdates,
}
})

2
new-client/src/shared/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const __API_BASE_URL__: string
declare const __COMMIT_SHA__: string

View File

@@ -0,0 +1,44 @@
<template>
<div class="auth-layout">
<div class="auth-layout__container">
<AppLogo class="auth-layout__logo" />
<div class="auth-layout__content">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppLogo from '@shared/components/AppLogo.vue'
defineOptions({
name: 'AuthLayout',
})
</script>
<style lang="scss">
.auth-layout {
display: flex;
height: 100%;
&__container {
margin: auto;
width: 100%;
max-width: 380px;
}
&__logo {
text-align: center;
margin-bottom: 24px;
}
&__content {
padding: 24px;
background-color: var(--bg);
border-radius: 36px;
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="default-layout">
<header class="app-header">
<div class="app-header__sidebar">
<AppLogo />
</div>
<div class="app-header__content">
Header
</div>
</header>
<aside class="app-sidebar">
Sidebar
</aside>
<div class="default-layout__content">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import AppLogo from '@shared/components/AppLogo.vue'
defineOptions({
name: 'DefaultLayout',
})
</script>
<style lang="scss">
.default-layout {
--sidebar-width: 320px;
height: 100%;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: 'header header' 'sidebar content';
&__content {
grid-area: content;
}
.app-header {
grid-area: header;
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
align-items: stretch;
background-color: var(--bg);
border-bottom: 1px solid var(--border-muted);
height: 52px;
&__sidebar {
border-right: 1px solid var(--border-muted);
}
&__sidebar,
&__content {
display: flex;
align-items: center;
padding: 8px;
}
&__title {
font-weight: 700;
}
&__version {
margin-left: 4px;
color: var(--text-muted);
font-size: 0.75rem;
}
}
.app-sidebar {
grid-area: sidebar;
background-color: var(--bg);
border-right: 1px solid var(--border-muted);
padding: 8px;
}
}
</style>

View File

@@ -0,0 +1,29 @@
@use 'tokens.scss';
html,
body,
[data-mount-point]:not(:empty) {
height: 100%;
}
[data-mount-point]:empty {
display: none;
}
body {
background-color: var(--bg-dark);
color: var(--text);
height: 100%;
}
*,
*::before,
*::after {
font-family: 'Inter', sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings:
'wdth' 100,
'YTLC' 500;
}

View File

@@ -0,0 +1,128 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View File

@@ -0,0 +1,566 @@
/* Document
* ========================================================================== */
/**
* Add border box sizing in all browsers (opinionated).
*/
*,
::before,
::after {
box-sizing: border-box;
}
/**
* 1. Add text decoration inheritance in all browsers (opinionated).
* 2. Add vertical alignment inheritance in all browsers (opinionated).
*/
::before,
::after {
text-decoration: inherit; /* 1 */
vertical-align: inherit; /* 2 */
}
/**
* 1. Use the default cursor in all browsers (opinionated).
* 2. Change the line height in all browsers (opinionated).
* 3. Use a 4-space tab width in all browsers (opinionated).
* 4. Remove the grey highlight on links in iOS (opinionated).
* 5. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
* 6. Breaks words to prevent overflow in all browsers (opinionated).
*/
html {
cursor: default; /* 1 */
line-height: 1.5; /* 2 */
-moz-tab-size: 4; /* 3 */
tab-size: 4; /* 3 */
-webkit-tap-highlight-color: transparent /* 4 */;
-ms-text-size-adjust: 100%; /* 5 */
-webkit-text-size-adjust: 100%; /* 5 */
word-break: break-word; /* 6 */
}
/* Sections
* ========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Edge, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
* ========================================================================== */
/**
* Remove the margin on nested lists in Chrome, Edge, IE, and Safari.
*/
dl dl,
dl ol,
dl ul,
ol dl,
ul dl {
margin: 0;
}
/**
* Remove the margin on nested lists in Edge 18- and IE.
*/
ol ol,
ol ul,
ul ol,
ul ul {
margin: 0;
}
/**
* 1. Add the correct sizing in Firefox.
* 2. Show the overflow in Edge 18- and IE.
*/
hr {
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* Add the correct display in IE.
*/
main {
display: block;
}
/**
* Remove the list style on navigation lists in all browsers (opinionated).
*/
nav ol,
nav ul {
list-style: none;
padding: 0;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
* ========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* Add the correct text decoration in Edge 18-, IE, and Safari.
*/
abbr[title] {
text-decoration: underline;
text-decoration: underline dotted;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/* Embedded content
* ========================================================================== */
/*
* Change the alignment on media elements in all browsers (opinionated).
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on iframes in all browsers (opinionated).
*/
iframe {
border-style: none;
}
/**
* Remove the border on images within links in IE 10-.
*/
img {
border-style: none;
}
/**
* Change the fill color to match the text color in all browsers (opinionated).
*/
svg:not([fill]) {
fill: currentColor;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Tabular data
* ========================================================================== */
/**
* Collapse border spacing in all browsers (opinionated).
*/
table {
border-collapse: collapse;
}
/* Forms
* ========================================================================== */
/**
* Remove the margin on controls in Safari.
*/
button,
input,
select {
margin: 0;
}
/**
* 1. Show the overflow in IE.
* 2. Remove the inheritance of text transform in Edge 18-, Firefox, and IE.
*/
button {
overflow: visible; /* 1 */
text-transform: none; /* 2 */
}
/**
* Correct the inability to style buttons in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* 1. Change the inconsistent appearance in all browsers (opinionated).
* 2. Correct the padding in Firefox.
*/
fieldset {
border: 1px solid #a0a0a0; /* 1 */
padding: 0.35em 0.75em 0.625em; /* 2 */
}
/**
* Show the overflow in Edge 18- and IE.
*/
input {
overflow: visible;
}
/**
* 1. Correct the text wrapping in Edge 18- and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
*/
legend {
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in Edge 18- and IE.
* 2. Add the correct vertical alignment in Chrome, Edge, and Firefox.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the inheritance of text transform in Firefox.
*/
select {
text-transform: none;
}
/**
* 1. Remove the margin in Firefox and Safari.
* 2. Remove the default vertical scrollbar in IE.
* 3. Change the resize direction in all browsers (opinionated).
*/
textarea {
margin: 0; /* 1 */
overflow: auto; /* 2 */
resize: vertical; /* 3 */
}
/**
* Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
padding: 0;
}
/**
* 1. Correct the odd appearance in Chrome, Edge, and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
* Correct the text style of placeholders in Chrome, Edge, and Safari.
*/
::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
/**
* Remove the inner padding in Chrome, Edge, and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style upload buttons in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/**
* Remove the inner border and padding of focus outlines in Firefox.
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus outline styles unset by the previous rule in Firefox.
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Remove the additional :invalid styles in Firefox.
*/
:-moz-ui-invalid {
box-shadow: none;
}
/* Interactive
* ========================================================================== */
/*
* Add the correct display in Edge 18- and IE.
*/
details {
display: block;
}
/*
* Add the correct styles in Edge 18-, IE, and Safari.
*/
dialog {
background-color: white;
border: solid;
color: black;
display: block;
height: -moz-fit-content;
height: -webkit-fit-content;
height: fit-content;
left: 0;
margin: auto;
padding: 1em;
position: absolute;
right: 0;
width: -moz-fit-content;
width: -webkit-fit-content;
width: fit-content;
}
dialog:not([open]) {
display: none;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
* ========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* User interaction
* ========================================================================== */
/*
* 1. Remove the tapping delay in IE 10.
* 2. Remove the tapping delay on clickable elements
in all browsers (opinionated).
*/
a,
area,
button,
input,
label,
select,
summary,
textarea,
[tabindex] {
-ms-touch-action: manipulation; /* 1 */
touch-action: manipulation; /* 2 */
}
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}
/* Accessibility
* ========================================================================== */
/**
* Change the cursor on busy elements in all browsers (opinionated).
*/
[aria-busy="true"] {
cursor: progress;
}
/*
* Change the cursor on control elements in all browsers (opinionated).
*/
[aria-controls] {
cursor: pointer;
}
/*
* Change the cursor on disabled, not-editable, or otherwise
* inoperable elements in all browsers (opinionated).
*/
[aria-disabled="true"],
[disabled] {
cursor: not-allowed;
}
/*
* Change the display on visually hidden accessible elements
* in all browsers (opinionated).
*/
[aria-hidden="false"][hidden] {
display: initial;
}
[aria-hidden="false"][hidden]:not(:focus) {
clip: rect(0, 0, 0, 0);
position: absolute;
}

View File

@@ -0,0 +1,64 @@
:root {
/* hsl (fallback color) */
--bg-dark: hsl(208 100% 2%);
--bg: hsl(201 86% 4%);
--bg-light: hsl(198 56% 8%);
--text: hsl(199 100% 93%);
--text-muted: hsl(199 36% 68%);
--highlight: hsl(199 28% 37%);
--border: hsl(198 40% 26%);
--border-muted: hsl(197 70% 15%);
--primary: hsl(198 69% 65%);
--secondary: hsl(20 68% 69%);
--danger: hsl(9 26% 64%);
--warning: hsl(52 19% 57%);
--success: hsl(146 17% 59%);
--info: hsl(217 28% 65%);
/* oklch */
--bg-dark: oklch(0.1 0.025 228);
--bg: oklch(0.15 0.025 228);
--bg-light: oklch(0.2 0.025 228);
--text: oklch(0.96 0.05 228);
--text-muted: oklch(0.76 0.05 228);
--highlight: oklch(0.5 0.05 228);
--border: oklch(0.4 0.05 228);
--border-muted: oklch(0.3 0.05 228);
--primary: oklch(0.76 0.1 228);
--secondary: oklch(0.76 0.1 48);
--danger: oklch(0.7 0.05 30);
--warning: oklch(0.7 0.05 100);
--success: oklch(0.7 0.05 160);
--info: oklch(0.7 0.05 260);
}
[data-theme="light"] {
/* hsl (fallback color) */
--bg-dark: hsl(199 53% 89%);
--bg: hsl(199 100% 94%);
--bg-light: hsl(199 100% 99%);
--text: hsl(204 100% 4%);
--text-muted: hsl(198 40% 26%);
--highlight: hsl(199 100% 99%);
--border: hsl(199 22% 49%);
--border-muted: hsl(199 28% 61%);
--primary: hsl(193 100% 16%);
--secondary: hsl(23 81% 25%);
--danger: hsl(9 21% 41%);
--warning: hsl(52 23% 34%);
--success: hsl(147 19% 36%);
--info: hsl(217 22% 41%);
/* oklch */
--bg-dark: oklch(0.92 0.025 228);
--bg: oklch(0.96 0.025 228);
--bg-light: oklch(1 0.025 228);
--text: oklch(0.15 0.05 228);
--text-muted: oklch(0.4 0.05 228);
--highlight: oklch(1 0.05 228);
--border: oklch(0.6 0.05 228);
--border-muted: oklch(0.7 0.05 228);
--primary: oklch(0.4 0.1 228);
--secondary: oklch(0.4 0.1 48);
--danger: oklch(0.5 0.05 30);
--warning: oklch(0.5 0.05 100);
--success: oklch(0.5 0.05 160);
--info: oklch(0.5 0.05 260);
}