first commit

This commit is contained in:
Net 2024-04-02 11:02:16 +07:00
commit e8ec46d19f
60 changed files with 13652 additions and 0 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
VITE_API_BASE_URL=
VITE_KC_URL=
VITE_KC_REALM=
VITE_KC_CLIENT_ID=
VITE_MANAGEMENT_ROLE=admin

8
.eslintignore Normal file
View file

@ -0,0 +1,8 @@
/dist
/src-capacitor
/src-cordova
/.quasar
/node_modules
.eslintrc.cjs
/src-ssr
/quasar.config.*.temporary.compiled*

61
.eslintrc.cjs Normal file
View file

@ -0,0 +1,61 @@
module.exports = {
root: true,
parserOptions: {
parser: require.resolve('@typescript-eslint/parser'),
extraFileExtensions: ['.vue'],
},
env: {
browser: true,
es2021: true,
node: true,
'vue/setup-compiler-macros': true,
},
extends: [
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
// ESLint typescript rules
'plugin:@typescript-eslint/recommended',
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
'plugin:vue/vue3-essential',
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier',
],
plugins: [
// required to apply rules which need type information
'@typescript-eslint',
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
// required to lint *.vue files
'vue',
],
globals: {
ga: 'readonly', // Google Analytics
cordova: 'readonly',
__statics: 'readonly',
__QUASAR_SSR__: 'readonly',
__QUASAR_SSR_SERVER__: 'readonly',
__QUASAR_SSR_CLIENT__: 'readonly',
__QUASAR_SSR_PWA__: 'readonly',
process: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly',
},
// add your custom rules here
rules: {
quotes: ['warn', 'single', { avoidEscape: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'prefer-promise-reject-errors': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
};

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# BEX related directories and files
/src-bex/www
/src-bex/js/core
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
# local .env files
.env.local*

5
.npmrc Normal file
View file

@ -0,0 +1,5 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
resolution-mode=highest

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"singleQuote": true,
"semi": true,
"htmlWhitespaceSensitivity": "ignore"
}

15
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"typescript.tsdk": "node_modules/typescript/lib"
}

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:20-slim as build-stage
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
FROM alpine as production-stage
WORKDIR /app
RUN apk add miniserve --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
COPY --from=build-stage /app/dist/spa .
COPY --from=build-stage /app/entrypoint.sh ./entrypoint.sh
RUN chmod u+x ./entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

0
README.md Normal file
View file

13
compose.yaml Normal file
View file

@ -0,0 +1,13 @@
version: '3.8'
services:
app:
build: .
ports:
- 3000:8080
environment:
- API_BASE_URL=
- KC_URL=
- KC_REALM=dev
- KC_CLIENT_ID=dev
- MANAGEMENT_ROLE=admin

12
entrypoint.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/sh
for file in /app/assets/*.js
do
sed -i 's|ENV_API_BASE_URL|'${API_BASE_URL}'|g' $file
sed -i 's|ENV_KC_URL|'${KC_URL}'|g' $file
sed -i 's|ENV_KC_REALM|'${KC_REALM}'|g' $file
sed -i 's|ENV_KC_CLIENT_ID|'${KC_CLIENT_ID}'|g' $file
sed -i 's|ENV_MANAGEMENT_ROLE|'${MANAGEMENT_ROLE}'|g' $file
done
miniserve --spa --index index.html

32
index.html Normal file
View file

@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="public/icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="public/icons/favicon-16x16.png"
/>
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

7264
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

49
package.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "jobs-worker-service",
"version": "0.0.1",
"description": "Jobs Worker Service",
"productName": "Jobs Worker Service",
"author": "Chamomind",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint --ext .js,.ts,.vue ./",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build"
},
"dependencies": {
"@quasar/extras": "^1.16.9",
"@tato30/vue-pdf": "^1.9.5",
"axios": "^1.6.8",
"keycloak-js": "^24.0.2",
"mime": "^4.0.1",
"moment": "^2.30.1",
"open-props": "^1.6.21",
"pinia": "^2.1.7",
"quasar": "^2.15.1",
"socket.io-client": "^4.7.5",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@quasar/app-vite": "2.0.0-beta.2",
"@types/node": "^20.11.30",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
"prettier": "^3.2.5",
"typescript": "^5.4.3"
},
"engines": {
"node": "^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

4154
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

BIN
public/book.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/logo_jws.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

50
quasar.config.ts Normal file
View file

@ -0,0 +1,50 @@
/* eslint-env node */
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
import { configure } from 'quasar/wrappers';
import { fileURLToPath } from 'node:url';
export default configure((ctx) => {
return {
eslint: {
fix: true,
warnings: true,
errors: true,
},
boot: ['i18n', 'axios', 'components'],
css: ['app.scss'],
extras: ['mdi-v7'],
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'history',
vitePlugins: [
[
'@intlify/unplugin-vue-i18n/vite',
{
ssr: ctx.modeName === 'ssr',
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
},
],
],
},
devServer: {
host: '0.0.0.0',
open: false,
},
framework: {
config: {},
plugins: ['Dark', 'Dialog'],
iconSet: 'mdi-v7',
cssAddon: true,
},
animations: [],
pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
},
};
});

5
src/App.vue Normal file
View file

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<router-view />
</template>
<style lang="scss"></style>

69
src/boot/axios.ts Normal file
View file

@ -0,0 +1,69 @@
import axios, { AxiosInstance } from 'axios';
import { boot } from 'quasar/wrappers';
import { getToken } from 'src/services/keycloak';
import { dialog } from 'src/stores/utils';
import useLoader from 'stores/loader';
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$axios: AxiosInstance;
$api: AxiosInstance;
}
}
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL });
function parseError(
status: number,
body?: { status: number; message: string },
) {
if (status === 422) return 'ข้อมูลไม่ถูกต้อง กรุณาตรวจสอบใหม่อีกครั้ง';
if (body && body.message) return body.message;
return 'เกิดข้อผิดพลาดทำให้ระบบไม่สามารถทำงานได้ กรุณาลองใหม่ในภายหลัง';
}
api.interceptors.request.use(async (config) => {
useLoader().show();
config.headers.Authorization = `Bearer ${await getToken()}`;
return config;
});
api.interceptors.response.use(
(res) => {
useLoader().hide();
return res;
},
(err) => {
useLoader().hide();
dialog({
color: 'negative',
icon: 'priority_high',
title: 'เกิดข้อผิดพลาด',
actionText: 'ตกลง',
persistent: true,
message: parseError(err.response.status, err.response.data),
action: () => {},
});
},
);
export default boot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
app.config.globalProperties.$axios = axios;
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api;
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
});
export { api };

8
src/boot/components.ts Normal file
View file

@ -0,0 +1,8 @@
import { boot } from 'quasar/wrappers';
import GlobalDialog from 'components/GlobalDialog.vue';
import GlobalLoading from 'components/GlobalLoading.vue';
export default boot(({ app }) => {
app.component('global-dialog', GlobalDialog);
app.component('global-loading', GlobalLoading);
});

33
src/boot/i18n.ts Normal file
View file

@ -0,0 +1,33 @@
import { boot } from 'quasar/wrappers';
import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
export type MessageLanguages = keyof typeof messages;
// Type-define 'en-US' as the master schema for the resource
export type MessageSchema = (typeof messages)['en-US'];
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-interface */
declare module 'vue-i18n' {
// define the locale messages schema
export interface DefineLocaleMessage extends MessageSchema {}
// define the datetime format schema
export interface DefineDateTimeFormat {}
// define the number format schema
export interface DefineNumberFormat {}
}
/* eslint-enable @typescript-eslint/no-empty-interface */
export default boot(({ app }) => {
const i18n = createI18n({
locale: 'th-th',
legacy: false,
messages,
});
// Set i18n instance on app
app.use(i18n);
});

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue';
const currentRoute = ref<string>('');
const labelMenu: {
label: string;
icon: string;
}[] = [
{ label: 'Dashboard', icon: 'file-account-outline' },
{ label: 'จัดการสาขา', icon: 'add' },
{ label: 'จัดการผู้ใช้งาน', icon: 'add' },
{ label: 'จัดการหลักสูตร', icon: 'add' },
];
const leftDrawerOpen = defineModel<boolean>('leftDrawerOpen', {
default: false,
});
</script>
<template>
<!-- Drawer -->
<q-drawer v-model="leftDrawerOpen" side="left" show-if-above>
<div class="main-bar">
<div
class="column items-center justify-center q-pa-xl cursor-pointer"
@click="$router.push('/')"
id="btn-drawer-home"
>
<q-img src="/logo.png" />
</div>
</div>
<div id="drawer-menu" class="q-pr-sm">
<q-item
v-for="v in labelMenu"
:key="v.label"
clickable
@click="currentRoute = v.label"
:class="{ active: currentRoute === v.label, dark: $q.dark.isActive }"
>
<q-item-section id="btn-drawer-back " class="test">
<q-item-label>
<!-- <q-icon :name="v.icon" size="sm" class="q-mr-xs" /> -->
{{ v.label }}
</q-item-label>
</q-item-section>
</q-item>
</div>
</q-drawer>
</template>
<style lang="scss" scoped>
#drawer-menu :deep(.q-item) {
color: var(--gray-6);
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
#drawer-menu :deep(.q-item.active) {
--_drawer-item-background-color: var(--brand-1) !important;
background-color: var(--_drawer-item-background-color) !important;
color: white;
border-left: 10px solid $secondary;
&.dark {
--_drawer-item-background-color: var(--gray-10) !important;
border: 1px solid var(--brand-1);
border-left: 10px solid $secondary;
}
}
.test {
border: 1px solid blue;
}
</style>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
defineProps<{
title: string;
message: string;
color?: string;
icon?: string;
actionText?: string;
cancelText?: string;
persistent?: boolean;
action?: (...args: unknown[]) => void;
cancel?: (...args: unknown[]) => void;
}>();
</script>
<template>
<q-dialog ref="dialogRef" :persistent="persistent || false">
<q-card class="q-pa-sm" style="min-width: 300px; max-width: 80%">
<q-card-section class="row q-pa-sm">
<div
class="q-pr-md"
v-if="icon"
style="display: flex; align-items: center"
>
<q-avatar
:icon="icon"
size="xl"
font-size="25px"
color="grey-2"
:text-color="color || 'primary'"
/>
</div>
<div class="col text-dark">
<span class="text-bold block">{{ title }}</span>
<span class="block">{{ message }}</span>
</div>
</q-card-section>
<q-card-actions align="right" v-if="action || cancel">
<q-btn
id="btn-cancel-dialog"
v-if="cancel"
@click="cancel"
:label="cancelText || $t('cancel')"
:color="color || 'primary'"
v-close-popup
flat
/>
<q-btn
id="btn-ok-dialog"
v-if="action"
@click="action"
:label="actionText || $t('ok')"
:color="color || 'primary'"
v-close-popup
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style lang="scss"></style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
visibility: boolean;
}>();
</script>
<template>
<q-inner-loading :showing="visibility" class="loader q-gutter-y-xl">
<q-spinner size="80px" color="primary" />
<span class="text-h5 text-weight-bold text-primary">
{{ $t('loading') }}
</span>
</q-inner-loading>
</template>
<style scoped>
.loader {
z-index: 1000;
}
</style>

View file

@ -0,0 +1,214 @@
<script setup lang="ts">
import AppBox from './app/AppBox.vue';
withDefaults(
defineProps<{
dark?: boolean;
width?: string;
color: 'purple' | 'green';
}>(),
{
width: '300px',
deletable: false,
color: 'green',
},
);
</script>
<template>
<div class="container">
<div style="aspect-ratio: 9/16" class="flip">
<AppBox
style="padding: 0"
:class="`${$q.dark.isActive ? 'dark ' : ''} color__${color}`"
class="surface-1 front"
bordered
>
<div class="row justify-center">
<q-card-section class="q-pt-xl img">
<q-avatar size="100px">
<img src="https://cdn.quasar.dev/img/avatar1.jpg" />
</q-avatar>
</q-card-section>
</div>
<div class="box-title">
<div class="rounded title">คคล</div>
</div>
<q-card-section class="no-padding">
<div class="column items-center justify-center">
<div class="row">นางสาวสดใจ แสนด</div>
<div class="row q-mb-md">HQ0001</div>
</div>
</q-card-section>
<div>
<q-scroll-area
class="front-scroll"
style="border-radius: 0px 0px 12px 12px"
>
<q-card-section v-for="v in [1, 2]" :key="v" class="overflow">
<div class="color-front-text"> บร/คคล ภาษาไทย</div>
<div>บรทเฟรปเป</div>
</q-card-section>
</q-scroll-area>
</div>
</AppBox>
<AppBox
style="padding: 0"
class="back"
:class="`${$q.dark.isActive ? 'dark ' : ''} color__${color}`"
bordered
>
<div
class="row q-pl-md q-pb-md items-center"
:class="{
'surface-1': !$q.dark.isActive,
'surface-2': $q.dark.isActive,
}"
style="border-radius: 12px 12px 0px 0px"
>
<q-card-section class="q-pt-xl img">
<q-avatar size="50px">
<img src="https://cdn.quasar.dev/img/avatar1.jpg" />
</q-avatar>
</q-card-section>
<div class="col-7 q-pl-md">
<div class="row">นางสาวสขใจ แสนด</div>
<div class="row">HQ0001</div>
</div>
</div>
<q-scroll-area
class="back-scroll"
:class="{
'surface-1': $q.dark.isActive,
'surface-2': !$q.dark.isActive,
}"
>
<q-card-section class="q-pa-md">
<div
v-for="v in [1, 2]"
:key="v"
class="bordered row q-pa-sm q-mb-sm rounded bg-color-text-1"
>
<div class="col-2 flex flex-center">{{ v }}</div>
<div class="col-10 rounded q-pa-sm bg-color-text-2">HQ0001</div>
</div>
</q-card-section>
</q-scroll-area>
</AppBox>
</div>
</div>
</template>
<style scoped>
.color-front-text {
color: var(--color-front-title);
}
.container {
width: 300px;
height: 500px;
}
.flip {
cursor: pointer;
width: 100%;
position: relative;
transition: transform 0.4s;
transform-style: preserve-3d;
&:hover {
transform: rotateY(180deg);
}
}
.front,
.back {
width: 100%;
height: min-content;
position: absolute;
backface-visibility: hidden;
}
.front,
.back {
--_color: var(--teal-6);
--_color-dark: var(--_color);
--_bg-front-scroll: hsla(var(--_color) / 0.05);
--_bg-back-detail-1: white;
--_bg-back-detail-2: hsla(var(--_color) / 0.05) !important;
.img {
background: hsl(var(--_color)) !important;
border-bottom-left-radius: 100px;
border-bottom-right-radius: 100px;
}
.title {
width: 50%;
height: 25px;
background: hsl(var(--_color)) !important;
position: relative;
bottom: 16px;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
.front-scroll {
border-radius: 0px 0px 5px 5px;
background: var(--_bg-front-scroll) !important;
height: 254px;
max-width: 300px;
}
.back-scroll {
height: 370px;
max-width: 300px;
border-radius: 0px 0px 15px 15px;
.bg-color-text-1 {
background: var(--_bg-back-detail-1);
}
.bg-color-text-2 {
background: var(--_bg-back-detail-2) !important;
}
}
&.dark {
color: hsl(--_color-dark) !important;
background-color: transparent !important;
--_bg-back-avatar: var(--gray-11) !important;
--_bg-back-detail-1: var(--gray-9) !important;
--_bg-front-scroll: var(--gray-11);
--_bg-back-detail-2: hsla(var(--_color) / 0.1) !important;
}
&.color__purple {
--_color: var(--purple-11-hsl);
}
&.color__green {
--_color: var(--green-9-hsl);
}
}
.back {
background: #f5f5f5;
transform: rotateY(180deg);
}
.rounded {
border-radius: 5px 5px 5px 5px;
}
.box-title {
display: flex;
justify-content: center;
}
</style>

View file

@ -0,0 +1,23 @@
<script lang="ts" setup>
defineProps<{ dark?: boolean; bordered?: boolean }>();
</script>
<template>
<div class="app-box" :class="{ dark, 'app-box__bordered': bordered }">
<slot />
</div>
</template>
<style scoped>
.app-box {
overflow: hidden;
background-color: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-4);
box-shadow: var(--shadow-1);
}
.app-box__bordered {
border: 1px solid var(--border-color);
}
</style>

View file

@ -0,0 +1,25 @@
<script lang="ts" setup>
defineProps<{ bordered?: boolean; dark?: boolean }>();
</script>
<template>
<div class="app-circle" :class="{ dark, 'app-circle__bordered': bordered }">
<slot />
</div>
</template>
<style scoped>
.app-circle {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-round);
background-color: var(--surface-1);
aspect-ratio: var(--ratio-square);
}
.app-circle__bordered {
border: 1px solid var(--border-color);
}
</style>

View file

@ -0,0 +1,112 @@
<script setup lang="ts">
import AppBox from 'components/app/AppBox.vue';
import AppCircle from 'components/app/AppCircle.vue';
defineProps<{
list: {
icon: string;
title: string;
caption: string;
color:
| 'green'
| 'red'
| 'orange'
| 'cyan'
| 'camo'
| 'purple'
| 'violet'
| 'indigo'
| 'lime';
}[];
}>();
</script>
<template>
<div class="menu-container">
<AppBox
class="column inline-flex items-center"
v-for="(v, i) in list"
:key="i"
:bordered="$q.dark.isActive"
>
<AppCircle
:class="`q-pa-sm menu-icon menu-icon__${v.color}${($q.dark.isActive && ' dark') || ''}`"
:bordered="$q.dark.isActive"
>
<q-icon size="3rem" :name="v.icon" />
</AppCircle>
<div class="column items-center q-mt-md text-center">
<span style="font-weight: var(--font-weight-8)">{{ v.title }}</span>
<span style="color: rgba(130 130 130 / 0.7)">{{ v.caption }}</span>
</div>
</AppBox>
</div>
</template>
<style scoped>
.menu-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--size-6);
& > * {
transition: 100ms border-color ease-in-out;
border: 1px solid transparent;
&:hover {
border-color: var(--brand-1);
}
}
}
.menu-icon {
--_color: var(--gray-7-hsl);
--_color-dark: var(--_color);
color: hsl(var(--_color)) !important;
background-color: hsla(var(--_color) / 0.1) !important;
&.dark {
color: hsl(--_color-dark) !important;
background-color: transparent !important;
}
&.menu-icon__green {
--_color: var(--green-6-hsl);
}
&.menu-icon__red {
--_color: var(--red-8-hsl);
}
&.menu-icon__orange {
--_color: var(--orange-6-hsl);
--_color-dark: var(--orange-5-hsl);
}
&.menu-icon__cyan {
--_color: var(--cyan-5-hsl);
--_color-dark: var(--cyan-4-hsl);
}
&.menu-icon__camo {
--_color: var(--camo-5-hsl);
--_color-dark: var(--cyan-4-hsl);
}
&.menu-icon__purple {
--_color: var(--purple-3-hsl);
--_color-dark: var(--purple-5-hsl);
}
&.menu-icon__violet {
--_color: var(--violet-5-hsl);
}
&.menu-icon__indigo {
--_color: var(--indigo-9-hsl);
}
&.menu-icon__lime {
--_color: var(--lime-11-hsl);
}
}
</style>

View file

@ -0,0 +1,112 @@
<script lang="ts" setup>
import AppBox from 'components/app/AppBox.vue';
import AppCircle from '../app/AppCircle.vue';
defineProps<{
list: {
name: string;
badge: string;
detail: { label: string; value: string }[];
male?: boolean;
female?: boolean;
disabled?: boolean;
}[];
}>();
</script>
<template>
<div class="person-container">
<AppBox
:bordered="$q.dark.isActive"
class="column"
style="padding: 0"
v-for="(v, i) in list"
:key="i"
>
<div class="q-pa-md column items-center">
<AppCircle class="surface-2 avatar q-mb-md" bordered>Avatar</AppCircle>
<span>{{ v.name }}</span>
<span
class="badge"
:class="{
'bg-gender': v.male || v.female,
'bg-gender__male': v.male,
'bg-gender__female': v.female,
}"
>
{{ v.badge }}
</span>
</div>
<q-separator />
<div
class="q-py-sm q-px-xl person-detail rounded-b"
:class="{
'bg-gender': v.male || v.female,
'bg-gender__light': v.male || v.female,
'bg-gender__male': v.male,
'bg-gender__female': v.female,
}"
>
<div v-for="(d, j) in v.detail" :key="j">
<span>{{ d.label }}</span>
<span>{{ d.value }}</span>
</div>
</div>
</AppBox>
</div>
</template>
<style scoped>
.person-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--size-6);
}
.avatar {
block-size: 7rem;
}
.badge {
display: inline-block;
border-radius: var(--radius-6);
background-color: var(--surface-2);
padding: 0 var(--size-6);
}
.person-detail {
display: grid;
flex-grow: 1;
grid-template-columns: repeat(2, 1fr);
gap: var(--size-2);
& > * {
display: flex;
flex-direction: column;
& > :first-child {
font-size: var(--font-size-0);
}
}
}
.bg-gender {
color: hsla(var(--_fg));
background-color: hsl(var(--_bg));
&.bg-gender__light {
color: unset;
background-color: hsla(var(--_bg) / 0.1);
}
&.bg-gender__male {
--_fg: 0 100 100%;
--_bg: var(--blue-5-hsl);
}
&.bg-gender__female {
--_fg: 0 100 100%;
--_bg: var(--pink-7-hsl);
}
}
</style>

56
src/css/app.scss Normal file
View file

@ -0,0 +1,56 @@
@import 'open-props/postcss/style';
@import 'open-props/colors-hsl';
@import 'open-props/shadows';
@import 'open-props/zindex';
html {
--brand-1: #035aa1;
--brand-2: #f50000;
--border-color: var(--gray-4);
--foreground: black;
--background: var(--gray-1);
--surface-0: var(--background);
--surface-1: white;
--surface-2: var(--gray-1);
--surface-3: var(--gray-2);
color: var(--foreground);
background-color: var(--background);
}
:where(.dark, .body--dark) {
--brand-1: var(--blue-7);
--brand-2: #f50000;
--border-color: var(--gray-8);
--foreground: white;
--background: var(--gray-10);
--surface-0: var(--background);
--surface-1: var(--gray-11);
--surface-2: var(--gray-10);
--surface-3: var(--gray-9);
}
.bordered {
border: 1px solid var(--border-color);
}
.rounded {
border-radius: var(--radius-3);
}
.surface-1 {
background-color: var(--surface-1) !important;
}
.surface-2 {
background-color: var(--surface-2) !important;
}
.surface-3 {
background-color: var(--surface-3) !important;
}

View file

@ -0,0 +1,40 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@100;200;300;400;500;600;700;800;900&display=swap');
$typography-font-family: 'Noto Sans Thai', sans-serif !default;
$primary: var(--brand-1);
$secondary: var(--brand-2);
$accent: #9c27b0;
$dark: var(--gray-11);
$dark-page: var(--gray-10);
$positive: #00bd9d;
$negative: #cc0004;
$info: #328bf3;
$warning: #ffc224;
$separator-color: var(--border-color);
$separator-dark-color: var(--border-color);
.q-separator {
transition: none;
}
.q-field__control {
color: $primary;
}
.q-field--outlined .q-field__control {
border-radius: var(--radius-2);
&::before {
transition: none;
border-color: var(--border-color);
border-radius: var(--radius-2);
}
&:hover::before {
border-color: var(--border-color);
}
}

9
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/* eslint-disable */
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

12
src/i18n/en-US/index.ts Normal file
View file

@ -0,0 +1,12 @@
// This is just an example,
// so you can safely delete all default props below
export default {
ok: 'Confirm',
cancel: 'Cancel',
failed: 'Failed',
success: 'Success',
search: 'Search',
download: 'Download',
save: 'Save',
};

7
src/i18n/index.ts Normal file
View file

@ -0,0 +1,7 @@
import enUS from './en-US';
import th from './th-th';
export default {
'en-US': enUS,
'th-th': th,
};

9
src/i18n/th-th/index.ts Normal file
View file

@ -0,0 +1,9 @@
export default {
ok: 'ยืนยัน',
cancel: 'ยกเลิก',
failed: 'เกิดข้อผิดพลาด',
success: 'สำเร็จ',
search: 'ค้นหา',
download: 'ดาวน์โหลด',
save: 'บันทึก',
};

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar';
import useLoader from 'stores/loader';
import DrawerComponent from 'components/DrawerComponent.vue';
const $q = useQuasar();
const loaderStore = useLoader();
const { visible } = storeToRefs(loaderStore);
const leftDrawerOpen = ref($q.screen.gt.sm);
</script>
<template>
<q-layout view="lHh Lpr lFf">
<q-header style="background-color: var(--background)">
<div class="row items-center justify-between q-py-md q-px-lg">
<q-btn
round
unelevated
id="drawer-toggler"
icon="mdi-backburger"
:class="{ bordered: $q.dark.isActive }"
class="surface-2"
style="color: var(--gray-6)"
@click="leftDrawerOpen = !leftDrawerOpen"
/>
<q-btn
round
unelevated
id="drawer-toggler"
icon="mdi-switch"
:class="{ bordered: $q.dark.isActive }"
class="surface-2"
style="color: var(--gray-6)"
@click="$q.dark.toggle()"
/>
</div>
</q-header>
<q-page-container style="background-color: transparent">
<q-page class="q-px-lg q-pa-md">
<router-view />
</q-page>
</q-page-container>
<drawer-component v-model:leftDrawerOpen="leftDrawerOpen" />
<global-loading :visibility="visible" />
</q-layout>
</template>

112
src/pages/MainPage.vue Normal file
View file

@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref } from 'vue';
import MenuItem from 'components/home/MenuItem.vue';
import PersonCard from 'src/components/home/PersonCard.vue';
import UsersDetailCardComponent from 'components/UsersDetailCardComponent.vue';
const menu = [
{
icon: 'mdi-home',
color: 'green',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'red',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'orange',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'cyan',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'camo',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'purple',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'violet',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'indigo',
title: 'Home',
caption: 'Home Caption',
},
{
icon: 'mdi-home',
color: 'lime',
title: 'Home',
caption: 'Home Caption',
},
] satisfies InstanceType<typeof MenuItem>['$props']['list'];
const person = [
{
name: 'Person 1',
badge: 'Badge 1',
detail: [
{ label: 'Label 1', value: 'Detail 1' },
{ label: 'Label 2', value: 'Detail 2' },
],
male: true,
},
{
name: 'Person 1',
badge: 'Badge 1',
detail: [
{ label: 'Label 1', value: 'Detail 1' },
{ label: 'Label 2', value: 'Detail 2' },
{ label: 'Label 3', value: 'Detail 3' },
],
female: true,
},
] satisfies InstanceType<typeof PersonCard>['$props']['list'];
const input = ref('');
</script>
<template>
<q-input
label="test"
v-model="input"
:rules="[(v) => (!!v && v.length > 3) || 'Required']"
outlined
></q-input>
<PersonCard :list="person" class="q-mb-md" />
<MenuItem :list="menu" />
<div class="row">
<users-detail-card-component class="q-pa-md" v-for="v in [1, 2]" :key="v" />
</div>
</template>
<style scoped>
.person {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--size-6);
}
</style>

View file

@ -0,0 +1,23 @@
<template>
<div
class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"
>
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts"></script>

9
src/quasar.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/* eslint-disable */
// Forces TS to apply `@quasar/app-vite` augmentations of `quasar` package
// Removing this would break `quasar/wrappers` imports as those typings are declared
// into `@quasar/app-vite`
// As a side effect, since `@quasar/app-vite` reference `quasar` to augment it,
// this declaration also apply `quasar` own
// augmentations (eg. adds `$q` into Vue component context)
/// <reference types="@quasar/app-vite" />

42
src/router/index.ts Normal file
View file

@ -0,0 +1,42 @@
import { route } from 'quasar/wrappers';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import { login } from 'src/services/keycloak';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
return new Promise((resolve) => {
login().then(() => {
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
resolve(Router);
});
});
});

25
src/router/routes.ts Normal file
View file

@ -0,0 +1,25 @@
import { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '',
redirect: { name: 'Home' },
component: () => import('layouts/MainLayout.vue'),
children: [
{
path: '/',
name: 'Home',
component: () => import('pages/MainPage.vue'),
},
],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/error/NotFound.vue'),
},
];
export default routes;

61
src/services/keycloak.ts Normal file
View file

@ -0,0 +1,61 @@
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: import.meta.env.VITE_KC_URL,
realm: import.meta.env.VITE_KC_REALM,
clientId: import.meta.env.VITE_KC_CLIENT_ID,
});
let init = false;
export const keycloakInstance = keycloak;
export async function login(cb?: (...args: unknown[]) => void) {
if (import.meta.env.VITE_KC_DISABLED) return cb?.();
const auth = !init
? await keycloak
.init({
onLoad: 'login-required',
responseMode: 'query',
checkLoginIframe: false,
})
.catch((e) => console.dir(e))
: await keycloak.login().catch((e) => console.dir(e));
if (auth) init = true;
if (auth && cb) cb();
}
export async function logout() {
await keycloak.logout();
}
export async function getToken() {
await keycloak.updateToken(60).catch(() => login());
return keycloak.token;
}
export function getUserId(): string | undefined {
return keycloak.tokenParsed?.sub;
}
export function getUsername(): string | undefined {
return keycloak.tokenParsed?.preferred_username;
}
export function getName(): string | undefined {
return keycloak.tokenParsed?.name;
}
export function getEmail(): string | undefined {
return keycloak.tokenParsed?.email;
}
export function getRole(): string[] | undefined {
return keycloak.tokenParsed?.roles ?? [];
}
export function isLoggedIn() {
return !!keycloak.token;
}

10
src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* eslint-disable */
/// <reference types="vite/client" />
// Mocks all files ending in `.vue` showing them as plain Vue instances
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

32
src/stores/index.ts Normal file
View file

@ -0,0 +1,32 @@
import { store } from 'quasar/wrappers';
import { createPinia } from 'pinia';
import { Router } from 'vue-router';
/*
* When adding new properties to stores, you should also
* extend the `PiniaCustomProperties` interface.
* @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties
*/
declare module 'pinia' {
export interface PiniaCustomProperties {
readonly router: Router;
}
}
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default store((/* { ssrContext } */) => {
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});

View file

@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
const useLoader = defineStore('loader-store', () => {
const visible = ref(false);
return {
visible,
toggle() {
visible.value = !visible.value;
},
show() {
visible.value = true;
},
hide() {
visible.value = false;
},
};
});
export default useLoader;

558
src/stores/storage/index.ts Normal file
View file

@ -0,0 +1,558 @@
import { computed, reactive, ref } from 'vue';
import { defineStore } from 'pinia';
import { io } from 'socket.io-client';
import axios from 'axios';
import { api } from 'src/boot/axios';
import useLoader from '../loader';
import { useUtils } from '../utils';
type Path = string;
export interface StorageFolder {
pathname: string;
name: string;
size?: number;
createdAt: string;
createdBy: string;
}
export interface StorageFile {
pathname: string;
path: string;
fileName: string;
fileSize: number;
fileType: string;
title: string;
description: string;
author: string;
category: string[];
keyword: string[];
updatedAt: string;
updatedBy: string;
createdAt: string;
createdBy: string;
}
export interface Structure extends StorageFolder {
folder: Structure[];
file: StorageFile[];
}
type Tree = Structure[];
export type SearchOptions = {
path?: string[];
recursive?: boolean;
exact?: boolean;
within?: string;
};
export type SearchInfo = {
field: string;
value: string;
exact?: boolean;
};
export type SearchOperator = {
AND?: (SearchInfo | SearchOperator)[];
OR?: (SearchInfo | SearchOperator)[];
};
export type FileMetadata = {
title?: string;
description?: string;
category?: string[];
keyword?: string[];
author?: string;
};
function normalizePath(path: string | string[]) {
return Array.isArray(path)
? path.join('/') + '/'
: path.split('/').filter(Boolean).join('/') + '/';
}
const useStorage = defineStore('storageStore', () => {
const utils = useUtils();
const loader = useLoader();
const init = ref(false);
const folder = ref<Record<Path, StorageFolder[]>>({});
const file = ref<Record<Path, StorageFile[]>>({});
const tree = computed(() => {
let structure: Tree = [];
// parse list of folder and list of file into tree
Object.entries(folder.value).forEach(([key, value]) => {
const arr = key.split('/').filter(Boolean);
// Once run then it is init
if (!init.value) init.value = true;
if (arr.length === 0) {
structure = value.map((v) => ({
pathname: v.pathname,
name: v.name,
createdAt: v.createdAt,
createdBy: v.createdBy,
folder: [],
file: [],
}));
} else {
let current: Structure | undefined;
// traverse into tree
arr.forEach((v, i) => {
current =
i === 0
? structure.find((x) => x.name === v)
: current?.folder.find((x) => x.name === v);
});
// set data in tree (object is references to the same object)
if (current) {
current.folder = value.map((v) => ({
pathname: v.pathname,
name: v.name,
createdAt: v.createdAt,
createdBy: v.createdBy,
folder: [],
file: [],
}));
current.file = file.value[key] ?? [];
}
}
});
return structure;
});
const currentFile = ref<StorageFile>();
const currentInfo = reactive<{
path: string;
dept: number;
}>({
path: '',
dept: 0,
});
if (!init.value) goto(sessionStorage.getItem('path') || '');
async function getStorage(path: string = '') {
const arr = path.split('/').filter(Boolean);
const res = await api.post<(typeof folder.value)[string]>('/storage/list', {
operation: 'folder',
path: arr,
});
if (res.status === 200 && res.data && Array.isArray(res.data))
folder.value[normalizePath(path)] = res.data.sort((a, b) =>
a.pathname.localeCompare(b.pathname),
);
}
async function getStorageFile(path: string = '') {
const arr = path.split('/').filter(Boolean);
const res = await api.post<(typeof file.value)[string]>('/storage/list', {
operation: 'file',
path: arr,
});
if (res.status === 200 && res.data && Array.isArray(res.data))
file.value[normalizePath(path)] = res.data.sort((a, b) =>
a.pathname.localeCompare(b.pathname),
);
}
async function goto(path: string = '', force = false) {
loader.show();
const arr = path.split('/').filter(Boolean);
// get all parent to the root structure
// this will also triggher init structure as it get root structure
for (let i = 0; i < arr.length; i++) {
const current = normalizePath(arr.slice(0, i - arr.length));
if (!folder.value[current] || force) await getStorage(current);
}
path = normalizePath(path);
// only get this path once, after that will get from socket.io-client instead
if (!folder.value[path] || force) await getStorage(path);
if (!file.value[path] || force) await getStorageFile(path);
currentFile.value = undefined;
currentInfo.path = path;
currentInfo.dept = path.split('/').filter(Boolean).length;
sessionStorage.setItem('path', path);
loader.hide();
}
async function gotoParent() {
const arr = currentInfo.path.split('/').filter(Boolean);
await goto(normalizePath(arr.slice(0, -1)));
}
// socket.io zone
const socket = io(new URL(import.meta.env.VITE_API_BASE_URL).origin);
socket.on('connect', () => console.info('Socket.io connected.'));
socket.on('disconnect', () => console.info('Socket.io disconnected.'));
socket.on(
'FolderCreate',
(data: {
pathname: string;
name: string;
createdAt: string;
createdBy: string;
}) => {
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (folder.value[path]) {
const idx = folder.value[path].findIndex(
(v) => v.pathname === data.pathname,
);
if (idx === -1) {
folder.value[path].push({
pathname: data.pathname,
name: data.name,
createdAt: data.createdAt,
createdBy: data.createdBy,
});
}
folder.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
}
},
);
// NOTE:
// API planned to make new endpoint that can move and rename in one go.
// Need to change if api handle move and rename file instead of just edit.
socket.on('FolderMove', (data: { from: string; to: string }) => {
const src = data.from.split('/').filter(Boolean);
const dst = data.to.split('/').filter(Boolean);
const path = normalizePath(src.slice(0, -1));
if (folder.value[path]) {
const val = folder.value[path].find((v) => v.pathname === data.from);
if (val) {
val.pathname = data.to;
val.name = dst[dst.length - 1];
}
}
const regex = new RegExp(`^${data.from}`);
for (const key in folder.value) {
if (key.startsWith(data.from)) {
folder.value[key.replace(regex, data.to)] = folder.value[key].map(
(v) => {
v.pathname = v.pathname.replace(regex, data.to);
return v;
},
);
delete folder.value[key];
}
}
for (const key in file.value) {
if (key.startsWith(data.from)) {
file.value[key.replace(regex, data.to)] = file.value[key].map((v) => {
v.pathname = v.pathname.replace(regex, data.to);
return v;
});
delete file.value[key];
}
}
});
socket.on('FolderDelete', (data: { pathname: string }) => {
for (const key in folder.value) {
if (key.startsWith(data.pathname)) {
delete folder.value[key];
}
}
for (const key in file.value) {
if (key.startsWith(data.pathname)) {
delete file.value[key];
}
}
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (folder.value[path]) {
folder.value[path] = folder.value[path].filter(
(v) => v.pathname !== data.pathname,
);
}
if (
currentInfo.path.length >= normalizePath(arr).length &&
currentInfo.path.startsWith(normalizePath(arr))
) {
utils.openDialog({
title: 'แจ้งเตือน',
message: 'ข้อมูลที่คุณกำลังเข้าถึงอยู่ถูกลบ',
});
goto();
}
});
socket.on('FileUpload', (data: StorageFile) => {
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (file.value[path]) {
const idx = file.value[path].findIndex(
(v) => v.pathname === data.pathname,
);
if (idx !== -1) file.value[path][idx] = data;
else file.value[path].push(data);
file.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
}
});
socket.on('FileDelete', (data: { pathname: string }) => {
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (file.value[path]) {
file.value[path] = file.value[path].filter(
(v) => v.pathname !== data.pathname,
);
}
});
socket.on('FileMove', (data: { from: StorageFile; to: StorageFile }) => {
const arr = data.from.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (file.value[path]) {
const idx = file.value[path].findIndex(
(v) => v.pathname === data.from.pathname,
);
if (idx !== -1) file.value[path][idx] = data.to;
}
});
socket.on('FileUpdate', (data: StorageFile) => {
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (file.value[path]) {
const idx = file.value[path].findIndex(
(v) => v.pathname === data.pathname,
);
if (idx !== -1) file.value[path][idx] = data;
}
});
socket.on('FileUploadRequest', (data: StorageFile) => {
const arr = data.pathname.split('/').filter(Boolean);
const path = normalizePath(arr.slice(0, -1));
if (file.value[path]) {
const idx = file.value[path].findIndex(
(v) => v.pathname === data.pathname,
);
if (idx !== -1) file.value[path][idx] = data;
else file.value[path].push(data);
file.value[path].sort((a, b) => a.pathname.localeCompare(b.pathname));
}
});
async function createFolder(name: string, path: string = currentInfo.path) {
loader.show();
if (
folder.value[normalizePath(path)]?.findIndex((v) => v.name === name) !==
-1
) {
utils.openDialog({
title: 'แจ้งเตือน',
message: `พบชื่อ \"${name}\" ซ้ำในระบบ`,
});
} else {
const arrayPath: string[] = path.split('/').filter(Boolean);
await api.post('/storage/folder', {
path: arrayPath,
name: name.replace(/^\./, ''),
});
}
loader.hide();
}
async function editFolder(name: string, path: string) {
loader.show();
const arrayPath: string[] = path.split('/').filter(Boolean);
const beforeName = arrayPath.pop();
await api.put('/storage/folder', {
from: {
name: beforeName,
path: arrayPath,
},
to: {
name: name,
path: arrayPath,
},
});
loader.hide();
}
async function deleteFolder(path: string) {
loader.show();
await api.delete<(typeof file.value)[string]>('/storage/folder', {
data: { path: path.split('/').filter(Boolean) },
});
loader.hide();
}
async function createFile(
file: File,
data: FileMetadata,
path: string = currentInfo.path,
) {
const arr = path.split('/').filter(Boolean);
loader.show();
const res = await api.post('/storage/file', {
path: arr,
file: file.name,
...data,
});
if (res && res.status === 200 && res.data && res.data.uploadUrl) {
await axios
.put(res.data.uploadUrl, file, {
headers: { 'Content-Type': file.type },
onUploadProgress: (e) => console.log(e),
})
.catch((e) => console.error(e));
}
loader.hide();
}
async function updateFile(pathname: string, data: FileMetadata, file?: File) {
const arr = pathname.split('/');
if (arr.length < 1) return;
loader.show();
const srcFile = arr.pop();
const res = await api.put('/storage/file', {
...data,
from: {
file: srcFile,
path: arr,
},
to: file?.name
? {
file: file.name,
path: arr,
}
: undefined,
upload: !!file,
});
if (res && res.status === 200 && res.data && res.data.uploadUrl) {
await axios
.put(res.data.uploadUrl, file, {
headers: { 'Content-Type': file?.type },
onUploadProgress: (e) => console.log(e),
})
.catch((e) => console.error(e));
}
loader.hide();
}
async function deleteFile(pathname: string) {
const arr = pathname.split('/');
if (arr.length < 1) return;
loader.show();
await api.delete('/storage/file', {
data: { path: arr.slice(0, -1), file: arr[arr.length - 1] },
});
loader.hide();
}
async function searchFile(params: SearchOperator & SearchOptions) {
loader.show();
return (await api
.post(`/search${params.within ? `?within=${params.within}` : ''}`, {
...params,
within: undefined,
})
.then((r) => r.data)
.finally(loader.hide)) as StorageFile[];
}
async function getFileUrl(pathname: string | undefined) {
if (!pathname) return;
const arr = pathname.split('/');
const file = arr.pop();
const res = await api.post<StorageFile & { downloadUrl: string }>(
'/storage/file/download',
{ path: arr, file },
);
if (res.status === 200 && res.data && res.data.downloadUrl) {
return res.data.downloadUrl;
}
}
async function downloadFile(pathname: string | undefined) {
if (!pathname) return;
const arr = pathname.split('/');
const file = arr.pop();
const res = await api.post<StorageFile & { downloadUrl: string }>(
'/storage/file/download',
{ path: arr, file },
);
if (res.status === 200 && res.data && res.data.downloadUrl) {
await axios
.get(res.data.downloadUrl, {
method: 'GET',
responseType: 'blob',
headers: {
'Content-Type': 'application/json',
Accept: res.data.fileType,
},
})
.then((r) => {
const a = document.createElement('a');
a.href = window.URL.createObjectURL(r.data);
a.download = res.data.fileName;
a.click();
});
}
}
return {
// information
currentInfo,
currentFile,
folder,
file,
tree,
// fetch
getStorage,
getStorageFile,
// traverse
goto,
gotoParent,
// operation
createFolder,
editFolder,
deleteFolder,
createFile,
updateFile,
deleteFile,
searchFile,
getFileUrl,
downloadFile,
};
});
export default useStorage;

10
src/stores/store-flag.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import 'quasar/dist/types/feature-flag';
declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags {
store: true;
}
}

19
src/stores/utils/index.ts Normal file
View file

@ -0,0 +1,19 @@
import { Dialog } from 'quasar';
import GlobalDialog from 'components/GlobalDialog.vue';
export function dialog(opts: {
title: string;
message: string;
color?: string;
icon?: string;
persistent?: boolean;
actionText?: string;
cancelText?: string;
action?: (...args: unknown[]) => unknown;
cancel?: (...args: unknown[]) => unknown;
}) {
Dialog.create({
component: GlobalDialog,
componentProps: opts,
});
}

10
src/types.ts Normal file
View file

@ -0,0 +1,10 @@
export type Result<T> = {
result: T;
};
export type PaginationResult<T> = {
result: T[];
page: number;
pageSize: number;
total: number;
};

22
src/utils/datetime.ts Normal file
View file

@ -0,0 +1,22 @@
import 'moment/dist/locale/th';
import moment from 'moment';
moment.locale('th');
export function setLocale(locale: string) {
moment.locale(locale);
}
export function dateFormat(
date?: string | Date | null,
fullmonth = false,
time = false,
) {
const m = moment(date);
if (!m.isValid()) return '';
const month = m.format(fullmonth ? 'MMMM' : 'MMM');
return m.format(`DD ${month} YYYY ${time ? ' HH:mm' : ''}`);
}

6
tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "@quasar/app-vite/tsconfig-preset",
"compilerOptions": {
"baseUrl": "."
}
}