@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.
Files changed (47) hide show
  1. package/README.md +129 -2
  2. package/app/app.config.ts +52 -0
  3. package/app/components/action/button.vue +35 -0
  4. package/app/components/content/badge.vue +9 -0
  5. package/app/components/content/card.vue +9 -0
  6. package/app/components/content/chart.vue +94 -0
  7. package/app/components/content/formatted-text.vue +127 -0
  8. package/app/components/content/table.vue +13 -0
  9. package/app/components/content/timeline.vue +9 -0
  10. package/app/components/feedback/alert.vue +9 -0
  11. package/app/components/feedback/loader.vue +53 -0
  12. package/app/components/feedback/loading-view.vue +21 -0
  13. package/app/components/feedback/modal.vue +93 -0
  14. package/app/components/feedback/toast.vue +79 -0
  15. package/app/components/feedback/tooltip.vue +9 -0
  16. package/app/components/file/list.vue +36 -0
  17. package/app/components/file/upload.vue +114 -0
  18. package/app/components/form/calendar.vue +10 -0
  19. package/app/components/form/check-box.vue +64 -0
  20. package/app/components/form/editor.vue +124 -0
  21. package/app/components/form/input-error.vue +59 -0
  22. package/app/components/form/input.vue +43 -0
  23. package/app/components/form/select.vue +10 -0
  24. package/app/components/form/terms.vue +54 -0
  25. package/app/components/form/toggle.vue +29 -0
  26. package/app/components/navigation/breadcrumb.vue +10 -0
  27. package/app/components/navigation/tabs.vue +9 -0
  28. package/app/components/page/dashboard.vue +84 -0
  29. package/app/components/page/error.vue +45 -0
  30. package/app/components/page/frame.vue +53 -0
  31. package/app/error.vue +10 -0
  32. package/app/languages/en-us.ts +87 -0
  33. package/app/languages/index.ts +15 -0
  34. package/app/languages/ja-jp.ts +87 -0
  35. package/app/languages/ko-kr.ts +87 -0
  36. package/app/plugins/modal.plugin.ts +103 -0
  37. package/app/plugins/module.ts +49 -0
  38. package/app/plugins/toast.plugin.ts +44 -0
  39. package/app/plugins/variables.plugin.ts +28 -0
  40. package/app/types/file.ts +8 -0
  41. package/assets/css/colors.css +61 -0
  42. package/assets/css/main.css +164 -0
  43. package/nuxt.config.ts +19 -0
  44. package/package.json +59 -4
  45. package/public/_deplois/font/LICENSE +104 -0
  46. package/public/_deplois/font/PretendardVariable.woff2 +0 -0
  47. package/public/_deplois/logo.svg +3 -0
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # @deplois/template
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,9 @@
1
+ <template lang='pug'>
2
+ UCard(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
+ UTimeline(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,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>