@deplois/template 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -2
- package/app/app.config.ts +52 -0
- package/app/components/action/button.vue +35 -0
- package/app/components/content/badge.vue +9 -0
- package/app/components/content/card.vue +9 -0
- package/app/components/content/chart.vue +94 -0
- package/app/components/content/formatted-text.vue +127 -0
- package/app/components/content/table.vue +13 -0
- package/app/components/content/timeline.vue +9 -0
- package/app/components/feedback/alert.vue +9 -0
- package/app/components/feedback/loader.vue +53 -0
- package/app/components/feedback/loading-view.vue +21 -0
- package/app/components/feedback/modal.vue +93 -0
- package/app/components/feedback/toast.vue +79 -0
- package/app/components/feedback/tooltip.vue +9 -0
- package/app/components/file/list.vue +36 -0
- package/app/components/file/upload.vue +114 -0
- package/app/components/form/calendar.vue +10 -0
- package/app/components/form/check-box.vue +64 -0
- package/app/components/form/editor.vue +124 -0
- package/app/components/form/input-error.vue +59 -0
- package/app/components/form/input.vue +43 -0
- package/app/components/form/select.vue +10 -0
- package/app/components/form/terms.vue +54 -0
- package/app/components/form/toggle.vue +29 -0
- package/app/components/navigation/breadcrumb.vue +10 -0
- package/app/components/navigation/tabs.vue +9 -0
- package/app/components/page/dashboard.vue +84 -0
- package/app/components/page/error.vue +45 -0
- package/app/components/page/frame.vue +53 -0
- package/app/error.vue +10 -0
- package/app/languages/en-us.ts +87 -0
- package/app/languages/index.ts +15 -0
- package/app/languages/ja-jp.ts +87 -0
- package/app/languages/ko-kr.ts +87 -0
- package/app/plugins/modal.plugin.ts +103 -0
- package/app/plugins/module.ts +49 -0
- package/app/plugins/toast.plugin.ts +44 -0
- package/app/plugins/variables.plugin.ts +28 -0
- package/app/types/file.ts +8 -0
- package/assets/css/colors.css +61 -0
- package/assets/css/main.css +164 -0
- package/nuxt.config.ts +19 -0
- package/package.json +59 -4
- package/public/_deplois/font/LICENSE +104 -0
- package/public/_deplois/font/PretendardVariable.woff2 +0 -0
- package/public/_deplois/logo.svg +3 -0
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @deplois/template
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Nuxt 4 + Nuxt UI 기반 deplois 공통 디자인 레이어입니다. 컴포넌트, 컬러 토큰, 한국어·영어·일본어, UI 플러그인, 폰트와 오류 페이지를 제공합니다. 서비스별 API·세션 코드와 데모 페이지는 포함하지 않습니다.
|
|
4
|
+
|
|
5
|
+
## 새 프로젝트
|
|
6
|
+
|
|
7
|
+
npm 게시 후 사용할 수 있습니다. 현재 검증 조합은 Nuxt 4.1.2 / Vue 3.5.22입니다.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm create nuxt@latest my-project -- -t v4
|
|
11
|
+
cd my-project
|
|
12
|
+
npm install
|
|
13
|
+
npm install @deplois/template
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// nuxt.config.ts
|
|
18
|
+
export default defineNuxtConfig({
|
|
19
|
+
extends: ['@deplois/template']
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```pug
|
|
24
|
+
//- app/app.vue
|
|
25
|
+
UApp(:locale='$languages.locale')
|
|
26
|
+
Toast
|
|
27
|
+
Modal
|
|
28
|
+
LoadingView(:status='$loadingView.contents.length !== 0' :content='$loadingView.contents[0]')
|
|
29
|
+
NuxtPage
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
위 Pug 코드는 `<template lang='pug'>` 안에 작성합니다. 실제 화면은 `app/pages/index.vue`부터 작성합니다.
|
|
33
|
+
|
|
34
|
+
```pug
|
|
35
|
+
main.flex.min-h-svh.items-center.justify-center
|
|
36
|
+
PageFrame(title='홈' subtitle='새 프로젝트를 시작해요.' name='my-project')
|
|
37
|
+
Button.bg-primary.text-white(:auto-loading='false') 시작하기
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 공통 기능
|
|
41
|
+
|
|
42
|
+
- 자동 등록: Button, Input, CheckBox, Toggle, Terms, Select, Calendar, Editor, Chart, Table, Card, Badge, Alert, Timeline, Tabs, Breadcrumb, Tooltip 등.
|
|
43
|
+
- 접두어가 있는 컴포넌트: FileUpload, FileList, PageFrame, PageDashboard, PageError.
|
|
44
|
+
- UI 플러그인: `$toast`, `$modal`, `$loadingView`, `$solveButton`.
|
|
45
|
+
- `$languages.value`: `ko-kr`, `en-us`, `ja-jp`. 정의되지 않은 언어는 `en-us`로 전환됩니다.
|
|
46
|
+
- `$languages.items[$languages.value]`: 앱 인스턴스별 사전. 컴포넌트 기본 문구만 포함합니다. 데모·대시보드 업무 데이터·샘플 문구는 포함하지 않습니다.
|
|
47
|
+
- `input`은 비어 있으며, 서비스에서 `language-key`에 필요한 속성을 직접 등록합니다.
|
|
48
|
+
- `app/app.config.ts`, props의 `ui`, class, 슬롯으로 스타일을 변경합니다.
|
|
49
|
+
- 페이지 전환 기본값은 `page` / `out-in`. 호스트의 `app.pageTransition`으로 변경할 수 있습니다.
|
|
50
|
+
- NuxtPage 옆에 조건부 본문이 있다면 NuxtPage를 별도 div로 감싸 전환 중 DOM 충돌을 피합니다.
|
|
51
|
+
|
|
52
|
+
```pug
|
|
53
|
+
PageDashboard(:menus='menus' v-model='selected')
|
|
54
|
+
template(#sidebar-logo)
|
|
55
|
+
img.size-6(src='/my-logo.svg' alt='')
|
|
56
|
+
span.font-semibold My service
|
|
57
|
+
template(#sidebar-footer)
|
|
58
|
+
span 사용자 정보
|
|
59
|
+
div
|
|
60
|
+
NuxtPage
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
모바일 메뉴는 기본 접힘 상태이며 열기/닫기, 메뉴 선택 후 자동 닫기를 지원합니다. 데스크톱에서는 항상 표시됩니다.
|
|
64
|
+
|
|
65
|
+
## 로컬 개발과 패키징
|
|
66
|
+
|
|
67
|
+
이 저장소의 `app/components`, `app/languages`, `assets/css`가 패키지 소스입니다. 별도의 front-template 원본과 자동 동기화되지 않습니다. 패키지 자체는 Nuxt 레이어 소스를 배포하고, 실제 빌드는 사용하는 프로젝트에서 실행합니다.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm ci
|
|
71
|
+
npm run check
|
|
72
|
+
npm pack --dry-run
|
|
73
|
+
npm pack
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`npm run check`는 Vue/Pug 및 TypeScript 문법 검사입니다. 전체 타입 검사나 브라우저 검증을 대체하지 않습니다. `npm pack` 전에 자동 실행됩니다. 파일 목록은 package.json의 files로 제한합니다.
|
|
77
|
+
|
|
78
|
+
이웃 폴더의 소비 프로젝트에서:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm install ../template
|
|
82
|
+
npm run build
|
|
83
|
+
npm run dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 배포
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
# 커밋 + push
|
|
90
|
+
npm run publish -- 0.1.0 "첫 번째 배포"
|
|
91
|
+
|
|
92
|
+
# 커밋 + push + npm 게시
|
|
93
|
+
npm login --registry=https://registry.npmjs.org/
|
|
94
|
+
npm run publish -- 0.1.0 "첫 번째 배포" --deploy
|
|
95
|
+
|
|
96
|
+
# 현재 package.json 버전을 npm에만 게시 (Git 작업 없음)
|
|
97
|
+
npm run deploy
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`npm run deploy`는 현재 폴더의 파일을 검사하고 현재 package.json 버전으로 패키징·게시합니다. 버전 변경, 커밋, push는 하지 않습니다. 이미 게시한 버전이면 npm에서 거절합니다.
|
|
101
|
+
|
|
102
|
+
기본 명령은 검사 → package.json/lockfile 버전 변경 → 변경 파일 전체 커밋 → 현재 브랜치를 origin에 push까지만 실행합니다. `--deploy`를 붙이면 push 전에 패키징하고 push 성공 후 공개 npmjs.org에도 게시합니다. 옵션이 없으면 npm 로그인 확인과 tgz 생성도 하지 않습니다. 공백이 있는 커밋 메시지는 따옴표로 감쌉니다. GitHub 빌드 번호는 더 이상 버전에 사용하지 않습니다.
|
|
103
|
+
|
|
104
|
+
`.gitignore`에 포함되지 않은 모든 변경 파일이 커밋됩니다. 커밋할 변경을 먼저 확인하세요. GitHub Actions는 검사만 하고 게시하지 않습니다. npm의 @deplois 게시 권한과 인증이 필요하며 npm의 2FA 요청이 나오면 완료합니다. 토큰을 코드나 저장소에 넣지 않습니다.
|
|
105
|
+
|
|
106
|
+
어느 단계든 실패하면 다음 단계는 실행하지 않습니다. push 후 npm 게시가 실패한 경우 Git 커밋은 되돌리지 않으며 검사한 tgz를 임시 폴더에 보관하고 재시도 명령을 출력합니다. 네트워크 오류라면 레지스트리에서 해당 버전의 게시 여부를 먼저 확인하세요. 성공하면 임시 tgz를 삭제합니다.
|
|
107
|
+
|
|
108
|
+
npm 게시 과정에서 publish 스크립트가 다시 호출되지 않도록 내부 명령은 `--ignore-scripts`를 사용합니다. 직접 `npm publish` 대신 위 명령을 사용하세요. 수동 복구도 출력된 `--ignore-scripts` 포함 명령을 사용합니다.
|
|
109
|
+
|
|
110
|
+
소비 프로젝트는 `npm install @deplois/template@latest` 후 다시 빌드합니다. 로컬 test-project는 `file:../template`으로 연결됩니다.
|
|
111
|
+
|
|
112
|
+
## 라이선스와 에셋
|
|
113
|
+
|
|
114
|
+
프로젝트 자체의 라이선스는 아직 지정하지 않아 `UNLICENSED`입니다. 공개 사용 허용 범위가 결정되면 변경하세요. Pretendard 가변 폰트는 동봉한 OFL 라이선스를 따릅니다. 폰트와 로고는 `/_deplois/` 경로에서 제공하며 Paperlogy는 기존 CDN을 사용합니다.
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
## 로컬 배포 명령
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
npm run publish -- 1.2.3 "커밋 메시지" --deploy
|
|
121
|
+
npm run publish -- major "커밋 메시지" --deploy
|
|
122
|
+
npm run publish -- minor "커밋 메시지" --deploy
|
|
123
|
+
npm run publish -- fetch "커밋 메시지" --deploy
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
현재 버전이 `1.2.3`일 때 `major`는 `2.0.0`, `minor`는 `1.3.0`, `fetch`는 `1.2.4`가 됩니다. 상위 버전을 올리면 하위 버전은 0으로 초기화합니다.
|
|
127
|
+
|
|
128
|
+
`--deploy`를 빼면 버전 변경·커밋·Git push까지만 진행합니다. `npm run deploy`는 현재 버전을 npm에만 배포합니다.
|
|
129
|
+
|
|
130
|
+
`scripts/publish.mjs`는 로컬 전용으로 Git 추적과 npm 패키지에서 제외합니다. 새로 clone한 환경에서 배포 명령을 쓰려면 이 파일을 별도로 복사해야 합니다.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export default defineAppConfig({
|
|
2
|
+
ui: {
|
|
3
|
+
colors: {
|
|
4
|
+
primary: 'brand',
|
|
5
|
+
secondary: 'violet',
|
|
6
|
+
neutral: 'slate',
|
|
7
|
+
success: 'emerald',
|
|
8
|
+
info: 'sky',
|
|
9
|
+
warning: 'amber',
|
|
10
|
+
error: 'rose'
|
|
11
|
+
},
|
|
12
|
+
card: {
|
|
13
|
+
slots: {
|
|
14
|
+
root: 'rounded-md',
|
|
15
|
+
header: 'p-4 sm:px-4',
|
|
16
|
+
body: 'p-4 sm:p-4',
|
|
17
|
+
footer: 'p-4 sm:px-4'
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
table: {
|
|
21
|
+
slots: {
|
|
22
|
+
root: 'rounded-md border border-default',
|
|
23
|
+
th: 'px-4 py-3 bg-muted text-sm font-semibold',
|
|
24
|
+
td: 'px-4 py-3 text-sm text-default'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
alert: { slots: { root: 'rounded-md', title: 'text-sm font-semibold', description: 'text-sm' } },
|
|
28
|
+
badge: { slots: { base: 'rounded-md text-xs' } },
|
|
29
|
+
tooltip: { slots: { content: 'rounded-md text-xs' } },
|
|
30
|
+
tabs: {
|
|
31
|
+
slots: {
|
|
32
|
+
list: 'rounded-md',
|
|
33
|
+
trigger: 'h-8 cursor-pointer disabled:cursor-not-allowed',
|
|
34
|
+
indicator: 'rounded-md motion-reduce:transition-none'
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
calendar: {
|
|
38
|
+
slots: { cellTrigger: 'rounded-md cursor-pointer data-disabled:cursor-not-allowed data-unavailable:cursor-not-allowed' }
|
|
39
|
+
},
|
|
40
|
+
button: {
|
|
41
|
+
slots: {
|
|
42
|
+
base: 'cursor-pointer disabled:cursor-not-allowed aria-disabled:cursor-not-allowed'
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
select: {
|
|
46
|
+
slots: {
|
|
47
|
+
base: 'h-8 rounded-md cursor-pointer disabled:cursor-not-allowed',
|
|
48
|
+
item: 'cursor-pointer data-disabled:cursor-not-allowed'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
button(
|
|
3
|
+
v-bind='$attrs'
|
|
4
|
+
:class='twMerge(button, normalizeClass($attrs.class))'
|
|
5
|
+
:type='props.type'
|
|
6
|
+
:disabled='props.disabled || isLoading'
|
|
7
|
+
:aria-busy='isLoading'
|
|
8
|
+
:data-loading='isLoading'
|
|
9
|
+
@click='isPending = props.autoLoading'
|
|
10
|
+
@loading-end='isPending = false'
|
|
11
|
+
)
|
|
12
|
+
span.pointer-events-none.absolute.left-3.flex.size-4.items-center.justify-center.opacity-0.transition-opacity(class='group-data-[loading=true]/button:opacity-100' :class='props.ui?.loader' aria-hidden='true')
|
|
13
|
+
slot(name='loading')
|
|
14
|
+
Loader.size-full
|
|
15
|
+
slot(:loading='isLoading')
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang='ts'>
|
|
19
|
+
import { normalizeClass } from 'vue'
|
|
20
|
+
import { twMerge } from 'tailwind-merge'
|
|
21
|
+
|
|
22
|
+
defineOptions({ inheritAttrs: false })
|
|
23
|
+
const button = 'relative flex h-8 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-md px-4 text-sm font-semibold transition-all duration-200 group/button disabled:cursor-not-allowed disabled:opacity-60 data-[loading=true]:cursor-wait data-[loading=true]:pl-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current motion-reduce:transition-none'
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
ui?: { loader?: string },
|
|
27
|
+
type?: 'button' | 'submit' | 'reset',
|
|
28
|
+
disabled?: boolean,
|
|
29
|
+
loading?: boolean,
|
|
30
|
+
autoLoading?: boolean
|
|
31
|
+
}>(), { type: 'button', disabled: false, loading: false, autoLoading: true })
|
|
32
|
+
|
|
33
|
+
const isPending = ref(false)
|
|
34
|
+
const isLoading = computed(() => props.loading || isPending.value)
|
|
35
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
UBadge(variant='soft' color='neutral' v-bind='$attrs')
|
|
3
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
4
|
+
slot(:name='name' v-bind='scope || {}')
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
defineOptions({ inheritAttrs: false })
|
|
9
|
+
</script>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
figure.min-w-0.text-primary(:aria-label='(props.label ?? $languages.items[$languages.value].components.chart.label)')
|
|
3
|
+
div.relative(v-if='points.length' @pointerleave='activeIndex = null')
|
|
4
|
+
svg.block.w-full(viewBox='0 0 640 260' role='group' :aria-labelledby='`${chartId}-title ${chartId}-description`')
|
|
5
|
+
title(:id='`${chartId}-title`') {{ (props.label ?? $languages.items[$languages.value].components.chart.label) }}
|
|
6
|
+
desc(:id='`${chartId}-description`') {{ points.map(point => `${point.label}: ${format(point.value)}`).join(', ') }}
|
|
7
|
+
g(v-for='tick in ticks' :key='tick.value')
|
|
8
|
+
line(class='stroke-(--ui-border)' x1='56' x2='624' :y1='tick.y' :y2='tick.y')
|
|
9
|
+
text(class='fill-(--ui-text-muted)' :style="{ fontSize: `${typeof props.axisSize == 'number' ? props.axisSize : props.axisSize.y ?? 12}px` }" x='48' :y='tick.y' dominant-baseline='middle' text-anchor='end') {{ format(tick.value) }}
|
|
10
|
+
line(class='stroke-(--ui-border-accented)' x1='56' x2='624' :y1='baseline' :y2='baseline')
|
|
11
|
+
path.fill-current(v-if="props.type == 'area'" :d='`${linePath} L ${points.at(-1)?.x} ${baseline} L ${points[0]?.x} ${baseline} Z`' opacity='.12')
|
|
12
|
+
path.fill-none.stroke-current(v-if="props.type != 'bar'" :d='linePath' stroke-width='2' stroke-linecap='round' stroke-linejoin='round')
|
|
13
|
+
g(v-for='(point, index) in points' :key='index')
|
|
14
|
+
rect.fill-current(v-if="props.type == 'bar'" :x='point.x - barWidth / 2' :y='Math.min(point.y, baseline)' :width='barWidth' :height='Math.abs(point.y - baseline)' rx='3')
|
|
15
|
+
circle.fill-current(v-else :cx='point.x' :cy='point.y' :r='Math.max(0, props.pointSize) / 2')
|
|
16
|
+
text(class='fill-(--ui-text-muted)' :style="{ fontSize: `${typeof props.axisSize == 'number' ? props.axisSize : props.axisSize.x ?? 12}px` }" v-if='index % Math.max(1, Math.ceil(points.length / 8)) == 0' :x='point.x' y='246' text-anchor='middle') {{ point.label.length > 10 ? point.label.slice(0, 9) + '…' : point.label }}
|
|
17
|
+
Transition(enter-from-class='opacity-0' leave-to-class='opacity-0')
|
|
18
|
+
g.pointer-events-none.transition-opacity.duration-150(v-if='activePoint' aria-hidden='true' class='motion-reduce:transition-none')
|
|
19
|
+
g(class='transition-transform duration-200 ease-out motion-reduce:transition-none' :style='{ transform: `translateX(${activePoint.x}px)` }')
|
|
20
|
+
line.stroke-current(x1='0' x2='0' y1='20' y2='220' stroke-dasharray='4 4' opacity='.4')
|
|
21
|
+
circle.stroke-current(class='fill-(--ui-bg) transition-transform duration-200 ease-out motion-reduce:transition-none' :style='{ transform: `translateY(${activePoint.y}px)` }' cx='0' cy='0' :r='Math.max(0, props.activePointSize) / 2' stroke-width='2')
|
|
22
|
+
g(v-if='props.tooltip')
|
|
23
|
+
rect.fill-transparent.outline-none(v-for='(point, index) in points' :key='index' :x='56 + index / points.length * 568' y='20' :width='568 / points.length' height='200' tabindex='0' :aria-label='`${point.label}: ${formatDetail(point.value)}`' :aria-describedby='activeIndex == index ? `${chartId}-tooltip` : undefined' @pointerenter='activeIndex = index' @focus='activeIndex = index' @blur='activeIndex = null' @keydown.esc='activeIndex = null')
|
|
24
|
+
Transition(enter-from-class='opacity-0' leave-to-class='opacity-0')
|
|
25
|
+
div.pointer-events-none.absolute.z-10.max-w-full.rounded-md.border.border-default.bg-elevated.px-3.py-2.text-xs.text-default.shadow-md(v-if='activePoint' :id='`${chartId}-tooltip`' role='tooltip' class='transition-[left,top,transform,opacity] duration-200 ease-out motion-reduce:transition-none' :style='tooltipStyle')
|
|
26
|
+
slot(name='tooltip' :point='activePoint')
|
|
27
|
+
p.font-medium {{ activePoint.label }}
|
|
28
|
+
p.mt-1.font-semibold.tabular-nums {{ formatDetail(activePoint.value) }}
|
|
29
|
+
div.flex.min-h-48.items-center.justify-center.rounded-md.border.border-dashed.border-default.text-sm.text-muted(v-else)
|
|
30
|
+
slot(name='empty') {{ props.empty ?? $languages.items[$languages.value].components.common.empty }}
|
|
31
|
+
figcaption.mt-2.text-xs.text-muted(v-if='$slots.caption')
|
|
32
|
+
slot(name='caption')
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang='ts'>
|
|
36
|
+
const { $languages } = useNuxtApp()
|
|
37
|
+
const props = withDefaults(defineProps<{
|
|
38
|
+
data: Array<{ label: string, value: number }>,
|
|
39
|
+
type?: 'line' | 'bar' | 'area',
|
|
40
|
+
label?: string,
|
|
41
|
+
locale?: string,
|
|
42
|
+
empty?: string,
|
|
43
|
+
tooltip?: boolean,
|
|
44
|
+
tooltipPosition?: 'top' | 'bottom',
|
|
45
|
+
tooltipOffset?: number,
|
|
46
|
+
pointSize?: number,
|
|
47
|
+
activePointSize?: number,
|
|
48
|
+
axisSize?: number | { x?: number, y?: number }
|
|
49
|
+
}>(), {
|
|
50
|
+
type: 'line',
|
|
51
|
+
tooltip: true,
|
|
52
|
+
tooltipPosition: 'top',
|
|
53
|
+
tooltipOffset: 8,
|
|
54
|
+
pointSize: 4,
|
|
55
|
+
activePointSize: 8,
|
|
56
|
+
axisSize: 12
|
|
57
|
+
})
|
|
58
|
+
const chartId = useId()
|
|
59
|
+
const activeIndex = ref<number | null>(null)
|
|
60
|
+
const values = computed(() => props.data.filter(item => Number.isFinite(item.value)))
|
|
61
|
+
const minimum = computed(() => Math.min(0, ...values.value.map(item => item.value)))
|
|
62
|
+
const maximum = computed(() => Math.max(0, ...values.value.map(item => item.value)) || (minimum.value == 0 ? 1 : 0))
|
|
63
|
+
const range = computed(() => maximum.value - minimum.value)
|
|
64
|
+
const baseline = computed(() => 220 - (0 - minimum.value) / range.value * 200)
|
|
65
|
+
const barWidth = computed(() => Math.min(40, 568 / Math.max(values.value.length, 1) * .6))
|
|
66
|
+
const points = computed(() => values.value.map((item, index) => ({
|
|
67
|
+
...item,
|
|
68
|
+
x: 56 + (index + .5) / values.value.length * 568,
|
|
69
|
+
y: 220 - (item.value - minimum.value) / range.value * 200
|
|
70
|
+
})))
|
|
71
|
+
const ticks = computed(() => Array.from({ length: 5 }, (_, index) => ({ value: minimum.value + range.value * index / 4, y: 220 - index * 50 })))
|
|
72
|
+
const activePoint = computed(() => props.tooltip && activeIndex.value !== null ? points.value[activeIndex.value] : undefined)
|
|
73
|
+
const tooltipStyle = computed(() => {
|
|
74
|
+
if (!activePoint.value) return {}
|
|
75
|
+
const radius = Math.max(0, props.pointSize, props.activePointSize) / 2
|
|
76
|
+
const above = props.tooltipPosition == 'top'
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
left: `${activePoint.value.x / 640 * 100}%`,
|
|
80
|
+
top: `calc(${(activePoint.value.y + (above ? -radius : radius)) / 260 * 100}% ${above ? '-' : '+'} ${Math.max(0, props.tooltipOffset)}px)`,
|
|
81
|
+
transform: `translate(${activePoint.value.x > 320 ? '-100%' : '0'}, ${above ? '-100%' : '0'})`
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
const linePath = computed(() => points.value.map((point, index) => `${index == 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '))
|
|
85
|
+
watch(() => [props.data, props.type, props.tooltip], () => { activeIndex.value = null })
|
|
86
|
+
|
|
87
|
+
function formatDetail (value: number) {
|
|
88
|
+
return new Intl.NumberFormat(props.locale ?? $languages.locale.code, { maximumFractionDigits: 20 }).format(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function format (value: number) {
|
|
92
|
+
return new Intl.NumberFormat(props.locale ?? $languages.locale.code, { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script lang='ts'>
|
|
2
|
+
import { defineComponent, h, resolveComponent, type VNodeChild } from 'vue'
|
|
3
|
+
|
|
4
|
+
function parseContent(value: string, variables: Record<string, unknown>, resolvingVariables: string[] = []): VNodeChild[] {
|
|
5
|
+
const nodes: VNodeChild[] = []
|
|
6
|
+
let text = ''
|
|
7
|
+
let index = 0
|
|
8
|
+
|
|
9
|
+
function flushText() {
|
|
10
|
+
if(!text) return
|
|
11
|
+
nodes.push(text)
|
|
12
|
+
text = ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
while(index < value.length) {
|
|
16
|
+
if(value[index] !== '$' && value[index] !== '#') {
|
|
17
|
+
text += value[index]
|
|
18
|
+
index++
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const variableMatch = value.slice(index).match(/^\$\{([^}]+)\}/)
|
|
23
|
+
if(variableMatch) {
|
|
24
|
+
flushText()
|
|
25
|
+
const [variableSyntax = '', variableName = ''] = variableMatch
|
|
26
|
+
const normalizedVariableName = variableName.trim()
|
|
27
|
+
const variable = variables[normalizedVariableName]
|
|
28
|
+
|
|
29
|
+
if(variable === undefined || variable === null) {
|
|
30
|
+
nodes.push(h('span', { class: 'mx-1 inline-block h-2 w-20 animate-pulse rounded-full bg-accented align-middle' }))
|
|
31
|
+
} else if(typeof variable === 'string' && !resolvingVariables.includes(normalizedVariableName) && resolvingVariables.length < 10) {
|
|
32
|
+
nodes.push(...parseContent(variable, variables, [...resolvingVariables, normalizedVariableName]))
|
|
33
|
+
} else {
|
|
34
|
+
nodes.push(String(variable))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
index += variableSyntax.length
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const iconMatch = value.slice(index).match(/^#i\(([^)]+)\)/)
|
|
42
|
+
if(iconMatch) {
|
|
43
|
+
flushText()
|
|
44
|
+
const [iconSyntax = '', iconName = ''] = iconMatch
|
|
45
|
+
nodes.push(h(resolveComponent('Icon'), {
|
|
46
|
+
name: iconName.trim(),
|
|
47
|
+
class: 'mx-0.5 inline-block size-4 align-[-0.2em]'
|
|
48
|
+
}))
|
|
49
|
+
index += iconSyntax.length
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const colorMatch = value.slice(index).match(/^#c\((#[\da-fA-F]{3,4}|#[\da-fA-F]{6}|#[\da-fA-F]{8}|[a-zA-Z]+)\)\{/)
|
|
54
|
+
if(colorMatch) {
|
|
55
|
+
flushText()
|
|
56
|
+
const [optionSyntax = '', color = ''] = colorMatch
|
|
57
|
+
const contentStart = index + optionSyntax.length
|
|
58
|
+
let depth = 1
|
|
59
|
+
let contentEnd = contentStart
|
|
60
|
+
|
|
61
|
+
while(contentEnd < value.length && depth > 0) {
|
|
62
|
+
const character = value.charAt(contentEnd)
|
|
63
|
+
if(character === '{') depth++
|
|
64
|
+
if(character === '}') depth--
|
|
65
|
+
contentEnd++
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if(depth === 0) {
|
|
69
|
+
nodes.push(h('span', { style: { color } }, parseContent(value.slice(contentStart, contentEnd - 1), variables, resolvingVariables)))
|
|
70
|
+
index = contentEnd
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const optionMatch = value.slice(index).match(/^#([bui]+)(?:\(([^)]+)\))?\{/)
|
|
76
|
+
if(optionMatch) {
|
|
77
|
+
const [optionSyntax = '', optionFlags = '', iconName] = optionMatch
|
|
78
|
+
const contentStart = index + optionSyntax.length
|
|
79
|
+
let depth = 1
|
|
80
|
+
let contentEnd = contentStart
|
|
81
|
+
|
|
82
|
+
while(contentEnd < value.length && depth > 0) {
|
|
83
|
+
const character = value.charAt(contentEnd)
|
|
84
|
+
if(character === '{') depth++
|
|
85
|
+
if(character === '}') depth--
|
|
86
|
+
contentEnd++
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if(depth === 0 && (!optionFlags.includes('i') || iconName)) {
|
|
90
|
+
flushText()
|
|
91
|
+
const children = parseContent(value.slice(contentStart, contentEnd - 1), variables, resolvingVariables)
|
|
92
|
+
if(optionFlags.includes('i')) {
|
|
93
|
+
children.unshift(h(resolveComponent('Icon'), {
|
|
94
|
+
name: iconName?.trim() ?? '',
|
|
95
|
+
class: 'mr-1 inline-block size-4 align-[-0.2em]'
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let content: VNodeChild = children
|
|
100
|
+
if(optionFlags.includes('b')) content = h('strong', Array.isArray(content) ? content : [content])
|
|
101
|
+
if(optionFlags.includes('u')) content = h('span', { class: 'underline' }, [content])
|
|
102
|
+
nodes.push(content)
|
|
103
|
+
index = contentEnd
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
text += value.charAt(index)
|
|
109
|
+
index++
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
flushText()
|
|
113
|
+
return nodes
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default defineComponent({
|
|
117
|
+
name: 'FormattedText',
|
|
118
|
+
inheritAttrs: false,
|
|
119
|
+
props: {
|
|
120
|
+
value: { type: String, required: true },
|
|
121
|
+
variables: { type: Object as () => Record<string, unknown>, default: () => ({}) }
|
|
122
|
+
},
|
|
123
|
+
setup(props, { attrs }) {
|
|
124
|
+
return () => h('span', attrs, parseContent(props.value, props.variables))
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
ClientOnly(fallback-tag='div' :fallback='$languages.items[$languages.value].components.table.loading')
|
|
3
|
+
UTable(ref='table' :empty='$languages.items[$languages.value].components.common.empty' v-bind='$attrs')
|
|
4
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
5
|
+
slot(:name='name' v-bind='scope || {}')
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup lang='ts'>
|
|
9
|
+
const { $languages } = useNuxtApp()
|
|
10
|
+
defineOptions({ inheritAttrs: false })
|
|
11
|
+
const table = useTemplateRef('table')
|
|
12
|
+
defineExpose({ tableApi: computed(() => table.value?.tableApi) })
|
|
13
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
UAlert(variant='subtle' color='info' v-bind='$attrs')
|
|
3
|
+
template(v-for='(_, name) in $slots' #[name]='scope')
|
|
4
|
+
slot(:name='name' v-bind='scope || {}')
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
defineOptions({ inheritAttrs: false })
|
|
9
|
+
</script>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
svg.loading(viewBox='10 10 20 20' aria-hidden='true')
|
|
3
|
+
circle(stroke-width='10%' r='9' cx='100%' cy='100%' fill='none' :class="props.ui?.track ?? 'stroke-current opacity-40'")
|
|
4
|
+
circle(stroke-width='10%' r='9' cx='100%' cy='100%' fill='none' :class="props.ui?.indicator ?? 'stroke-current'")
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang='ts'>
|
|
8
|
+
const props = defineProps<{ ui?: Partial<Record<'track' | 'indicator', string>> }>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<style lang='css' scoped>
|
|
12
|
+
svg.loading {
|
|
13
|
+
animation: 1400ms linear 0s infinite normal none running loading-svg;
|
|
14
|
+
display: block;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
svg.loading > circle:last-child {
|
|
18
|
+
stroke-dasharray: 14px, 56px;
|
|
19
|
+
stroke-dashoffset: 0px;
|
|
20
|
+
animation: 1400ms ease-in-out 0s infinite normal none running loading-circle;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@keyframes loading-circle {
|
|
24
|
+
0% {
|
|
25
|
+
stroke-dasharray: 1px, 90px;
|
|
26
|
+
stroke-dashoffset: 0px;
|
|
27
|
+
}
|
|
28
|
+
50% {
|
|
29
|
+
stroke-dasharray: 45px, 90px;
|
|
30
|
+
stroke-dashoffset: -7px;
|
|
31
|
+
}
|
|
32
|
+
100% {
|
|
33
|
+
stroke-dasharray: 45px, 90px;
|
|
34
|
+
stroke-dashoffset: -56px;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@keyframes loading-svg {
|
|
39
|
+
0% {
|
|
40
|
+
transform: rotate(0deg);
|
|
41
|
+
transform-origin: 50% 50%;
|
|
42
|
+
}
|
|
43
|
+
100% {
|
|
44
|
+
transform: rotate(360deg);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
@media (prefers-reduced-motion: reduce) {
|
|
48
|
+
svg.loading,
|
|
49
|
+
svg.loading > circle:last-child {
|
|
50
|
+
animation: none;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<template lang='pug'>
|
|
2
|
+
Teleport(to='body')
|
|
3
|
+
Transition(enter-active-class='transition-opacity duration-200 motion-reduce:transition-none' leave-active-class='transition-opacity duration-200 motion-reduce:transition-none' enter-from-class='opacity-0' leave-to-class='opacity-0')
|
|
4
|
+
div.fixed.inset-0.z-50.flex.items-center.justify-center.p-4.backdrop-blur-sm(v-if='props.status' v-bind='$attrs' :class="props.ui?.overlay ?? 'bg-black/30 dark:bg-black/50'" role='status' aria-live='polite' :aria-label='$languages.items[$languages.value].components.common.loading')
|
|
5
|
+
div.flex.max-w-sm.items-center.gap-3.rounded-md.p-4.shadow-lg(:class="props.ui?.panel ?? 'bg-white text-slate-900 dark:bg-zinc-900 dark:text-white'")
|
|
6
|
+
slot(:content='props.content')
|
|
7
|
+
slot(name='loader')
|
|
8
|
+
Loader.size-5.shrink-0(:class="props.ui?.loader ?? 'text-black dark:text-white'")
|
|
9
|
+
slot(name='content' :content='props.content')
|
|
10
|
+
p.text-sm.font-medium.paperlogy(v-if='props.content' :class='props.ui?.content') {{ props.content }}
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup lang='ts'>
|
|
14
|
+
const { $languages } = useNuxtApp()
|
|
15
|
+
defineOptions({ inheritAttrs: false })
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
status: boolean,
|
|
18
|
+
content?: string,
|
|
19
|
+
ui?: Partial<Record<'overlay' | 'panel' | 'loader' | 'content', string>>
|
|
20
|
+
}>()
|
|
21
|
+
</script>
|