aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc-auto-import.json34
-rw-r--r--components.d.ts16
-rw-r--r--src/components/FormatTransformer.vue4
-rw-r--r--src/modules/shared/date.models.ts7
-rw-r--r--src/shims.d.ts6
-rw-r--r--src/tools/camera-recorder/camera-recorder.vue202
-rw-r--r--src/tools/camera-recorder/index.ts12
-rw-r--r--src/tools/camera-recorder/useMediaRecorder.ts88
-rw-r--r--src/tools/index.ts5
-rw-r--r--src/ui/c-alert/c-alert.demo.vue11
-rw-r--r--src/ui/c-alert/c-alert.theme.ts25
-rw-r--r--src/ui/c-alert/c-alert.vue32
-rw-r--r--src/ui/c-button/c-button.demo.vue2
-rw-r--r--src/ui/c-button/c-button.theme.ts12
-rw-r--r--src/ui/c-button/c-button.vue2
15 files changed, 448 insertions, 10 deletions
diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json
index 363273a..744e14d 100644
--- a/.eslintrc-auto-import.json
+++ b/.eslintrc-auto-import.json
@@ -22,7 +22,9 @@
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
+ "createReusableTemplate": true,
"createSharedComposable": true,
+ "createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
@@ -42,9 +44,6 @@
"isReactive": true,
"isReadonly": true,
"isRef": true,
- "logicAnd": true,
- "logicNot": true,
- "logicOr": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
@@ -97,6 +96,7 @@
"toReactive": true,
"toRef": true,
"toRefs": true,
+ "toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
@@ -107,6 +107,19 @@
"unrefElement": true,
"until": true,
"useActiveElement": true,
+ "useAnimate": true,
+ "useArrayDifference": true,
+ "useArrayEvery": true,
+ "useArrayFilter": true,
+ "useArrayFind": true,
+ "useArrayFindIndex": true,
+ "useArrayFindLast": true,
+ "useArrayIncludes": true,
+ "useArrayJoin": true,
+ "useArrayMap": true,
+ "useArrayReduce": true,
+ "useArraySome": true,
+ "useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
@@ -117,8 +130,8 @@
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
- "useClamp": true,
"useClipboard": true,
+ "useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
@@ -192,12 +205,18 @@
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
+ "useParentElement": true,
+ "usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
+ "usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
+ "usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
+ "usePreferredReducedMotion": true,
+ "usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
@@ -211,14 +230,17 @@
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
+ "useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
+ "useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
+ "useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
@@ -230,6 +252,8 @@
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
+ "useToNumber": true,
+ "useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
@@ -250,8 +274,10 @@
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
+ "watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
+ "watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
diff --git a/components.d.ts b/components.d.ts
index 40b805a..f4d98ac 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -19,6 +19,9 @@ declare module '@vue/runtime-core' {
Bcrypt: typeof import('./src/tools/bcrypt/bcrypt.vue')['default']
BenchmarkBuilder: typeof import('./src/tools/benchmark-builder/benchmark-builder.vue')['default']
Bip39Generator: typeof import('./src/tools/bip39-generator/bip39-generator.vue')['default']
+ CAlert: typeof import('./src/ui/c-alert/c-alert.vue')['default']
+ 'CAlert.demo': typeof import('./src/ui/c-alert/c-alert.demo.vue')['default']
+ CameraRecorder: typeof import('./src/tools/camera-recorder/camera-recorder.vue')['default']
CaseConverter: typeof import('./src/tools/case-converter/case-converter.vue')['default']
CButton: typeof import('./src/ui/c-button/c-button.vue')['default']
'CButton.demo': typeof import('./src/ui/c-button/c-button.demo.vue')['default']
@@ -57,11 +60,24 @@ declare module '@vue/runtime-core' {
HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default']
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
+ IconMdiCamera: typeof import('~icons/mdi/camera')['default']
+ IconMdiCameraOutline: typeof import('~icons/mdi/camera-outline')['default']
+ IconMdiCameraVideoOff: typeof import('~icons/mdi/camera-video-off')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
+ IconMdiDelete: typeof import('~icons/mdi/delete')['default']
+ IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
+ IconMdiDeleteOutlined: typeof import('~icons/mdi/delete-outlined')['default']
+ IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
+ IconMdiPause: typeof import('~icons/mdi/pause')['default']
+ IconMdiPlay: typeof import('~icons/mdi/play')['default']
+ IconMdiRecord: typeof import('~icons/mdi/record')['default']
+ IconMdiRecordRec: typeof import('~icons/mdi/record-rec')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
+ IconMdiStopCircle: typeof import('~icons/mdi/stop-circle')['default']
+ IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
diff --git a/src/components/FormatTransformer.vue b/src/components/FormatTransformer.vue
index 5b187b2..96f7798 100644
--- a/src/components/FormatTransformer.vue
+++ b/src/components/FormatTransformer.vue
@@ -4,10 +4,10 @@
v-model:value="input"
:placeholder="inputPlaceholder"
:label="inputLabel"
- multiline
- autosize
rows="20"
+ autosize
raw-text
+ multiline
test-id="input"
:validation-rules="inputValidationRules"
/>
diff --git a/src/modules/shared/date.models.ts b/src/modules/shared/date.models.ts
new file mode 100644
index 0000000..37cdbad
--- /dev/null
+++ b/src/modules/shared/date.models.ts
@@ -0,0 +1,7 @@
+import { format } from 'date-fns';
+
+export { getUrlFriendlyDateTime };
+
+function getUrlFriendlyDateTime({ date = new Date() }: { date?: Date } = {}) {
+ return format(date, 'yyyy-MM-dd-HH-mm-ss');
+}
diff --git a/src/shims.d.ts b/src/shims.d.ts
index f8798e5..fc76e21 100644
--- a/src/shims.d.ts
+++ b/src/shims.d.ts
@@ -8,3 +8,9 @@ declare module '*.md' {
const Component: ComponentOptions;
export default Component;
}
+
+declare module '~icons/*' {
+ import { FunctionalComponent, SVGAttributes } from 'vue';
+ const component: FunctionalComponent<SVGAttributes>;
+ export default component;
+}
diff --git a/src/tools/camera-recorder/camera-recorder.vue b/src/tools/camera-recorder/camera-recorder.vue
new file mode 100644
index 0000000..81fec42
--- /dev/null
+++ b/src/tools/camera-recorder/camera-recorder.vue
@@ -0,0 +1,202 @@
+<template>
+ <div>
+ <c-card v-if="!isSupported"> Your browser does not support recording video from camera </c-card>
+
+ <c-card v-else-if="!permissionGranted" text-center>
+ You need to grant permission to use your camera and microphone
+
+ <c-alert v-if="permissionCannotBePrompted" mt-4 text-left>
+ Your browser has blocked permission request or does not support it. You need to grant permission manually in
+ your browser settings (usually the lock icon in the address bar).
+ </c-alert>
+
+ <div v-else mt-4 flex justify-center>
+ <c-button @click="requestPermissions">Grant permission</c-button>
+ </div>
+ </c-card>
+
+ <c-card v-else>
+ <div flex gap-2>
+ <div flex-1>
+ <div>Video</div>
+ <n-select
+ v-model:value="currentCamera"
+ :options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select camera"
+ />
+ </div>
+ <div v-if="currentMicrophone && microphones.length > 0" flex-1>
+ <div>Audio</div>
+ <n-select
+ v-model:value="currentMicrophone"
+ :options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
+ placeholder="Select microphone"
+ />
+ </div>
+ </div>
+
+ <div v-if="!isMediaStreamAvailable" mt-3 flex justify-center>
+ <c-button type="primary" @click="start">Start webcam</c-button>
+ </div>
+
+ <div v-else>
+ <div my-2>
+ <video ref="video" autoplay controls playsinline max-h-full w-full />
+ </div>
+
+ <div flex items-center justify-between gap-2>
+ <c-button :disabled="!isMediaStreamAvailable" @click="takeScreenshot">
+ <span mr-2> <icon-mdi-camera /></span>
+ Take screenshot
+ </c-button>
+
+ <div v-if="isRecordingSupported" flex justify-center gap-2>
+ <c-button v-if="recordingState === 'stopped'" @click="startRecording">
+ <span mr-2> <icon-mdi-video /></span>
+ Start recording
+ </c-button>
+
+ <c-button v-if="recordingState === 'recording'" @click="pauseRecording">
+ <span mr-2> <icon-mdi-pause /></span>
+ Pause
+ </c-button>
+
+ <c-button v-if="recordingState === 'paused'" @click="resumeRecording">
+ <span mr-2> <icon-mdi-play /></span>
+ Resume
+ </c-button>
+
+ <c-button v-if="recordingState !== 'stopped'" type="error" @click="stopRecording">
+ <span mr-2> <icon-mdi-record /></span>
+ Stop
+ </c-button>
+ </div>
+ <div v-else italic op-60>Video recording is not supported in your browser</div>
+ </div>
+ </div>
+ </c-card>
+
+ <div grid grid-cols-2 mt-5 gap-2>
+ <c-card v-for="({ type, value, createdAt }, index) in medias" :key="index">
+ <img v-if="type === 'image'" :src="value" max-h-full w-full alt="screenshot" />
+
+ <video v-else :src="value" controls max-h-full w-full />
+
+ <div flex items-center justify-between>
+ <div font-bold>{{ type === 'image' ? 'Screenshot' : 'Video' }}</div>
+
+ <div flex gap-2>
+ <c-button @click="downloadMedia({ type, value, createdAt })">
+ <icon-mdi-download />
+ </c-button>
+
+ <c-button @click="medias = medias.filter((_ignored, i) => i !== index)">
+ <icon-mdi-delete-outline />
+ </c-button>
+ </div>
+ </div>
+ </c-card>
+ </div>
+ </div>
+</template>
+
+<script setup lang="ts">
+import _ from 'lodash';
+
+import { useMediaRecorder } from './useMediaRecorder';
+
+type Media = { type: 'image' | 'video'; value: string; createdAt: Date };
+
+const {
+ videoInputs: cameras,
+ audioInputs: microphones,
+ permissionGranted,
+ isSupported,
+ ensurePermissions,
+} = useDevicesList({
+ requestPermissions: true,
+ constraints: { video: true, audio: true },
+ onUpdated() {
+ refreshCurrentDevices();
+ },
+});
+
+const video = ref<HTMLVideoElement>();
+const medias = ref<Media[]>([]);
+const currentCamera = ref(cameras.value[0]?.deviceId);
+const currentMicrophone = ref(microphones.value[0]?.deviceId);
+const permissionCannotBePrompted = ref(false);
+
+const {
+ stream,
+ start,
+ enabled: isMediaStreamAvailable,
+} = useUserMedia({
+ constraints: computed(() => ({
+ video: { deviceId: currentCamera.value },
+ ...(currentMicrophone.value ? { audio: { deviceId: currentMicrophone.value } } : {}),
+ })),
+ autoSwitch: true,
+});
+
+const {
+ isRecordingSupported,
+ onRecordAvailable,
+ startRecording,
+ stopRecording,
+ pauseRecording,
+ recordingState,
+ resumeRecording,
+} = useMediaRecorder({
+ stream,
+});
+
+onRecordAvailable((value) => {
+ medias.value.unshift({ type: 'video', value, createdAt: new Date() });
+});
+
+function refreshCurrentDevices() {
+ console.log('refreshCurrentDevices');
+
+ if (_.isNil(currentCamera) || !cameras.value.find((i) => i.deviceId === currentCamera.value)) {
+ currentCamera.value = cameras.value[0]?.deviceId;
+ }
+
+ if (_.isNil(microphones) || !microphones.value.find((i) => i.deviceId === currentMicrophone.value)) {
+ currentMicrophone.value = microphones.value[0]?.deviceId;
+ }
+}
+
+function takeScreenshot() {
+ if (!video.value) return;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = video.value.videoWidth;
+ canvas.height = video.value.videoHeight;
+ canvas.getContext('2d')?.drawImage(video.value, 0, 0);
+ const image = canvas.toDataURL('image/png');
+
+ medias.value.unshift({ type: 'image', value: image, createdAt: new Date() });
+}
+
+watchEffect(() => {
+ if (video.value && stream.value) video.value.srcObject = stream.value;
+});
+
+async function requestPermissions() {
+ try {
+ await ensurePermissions();
+ } catch (e) {
+ permissionCannotBePrompted.value = true;
+ }
+}
+
+function downloadMedia({ type, value, createdAt }: Media) {
+ const link = document.createElement('a');
+ link.href = value;
+ link.download = `${type}-${createdAt.getTime()}.${type === 'image' ? 'png' : 'webm'}`;
+ link.click();
+}
+</script>
+
+<style lang="less" scoped></style>
diff --git a/src/tools/camera-recorder/index.ts b/src/tools/camera-recorder/index.ts
new file mode 100644
index 0000000..3c5d11b
--- /dev/null
+++ b/src/tools/camera-recorder/index.ts
@@ -0,0 +1,12 @@
+import { Camera } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Camera recorder',
+ path: '/camera-recorder',
+ description: 'Take a picture or record a video from your webcam or camera.',
+ keywords: ['camera', 'recoder'],
+ component: () => import('./camera-recorder.vue'),
+ icon: Camera,
+ createdAt: new Date('2023-05-15'),
+});
diff --git a/src/tools/camera-recorder/useMediaRecorder.ts b/src/tools/camera-recorder/useMediaRecorder.ts
new file mode 100644
index 0000000..eed0edd
--- /dev/null
+++ b/src/tools/camera-recorder/useMediaRecorder.ts
@@ -0,0 +1,88 @@
+import { computed, ref, type Ref } from 'vue';
+
+export { useMediaRecorder };
+
+function useMediaRecorder({ stream }: { stream: Ref<MediaStream | undefined> }): {
+ isRecordingSupported: Ref<boolean>;
+ recordingState: Ref<'stopped' | 'recording' | 'paused'>;
+ startRecording: () => void;
+ stopRecording: () => void;
+ pauseRecording: () => void;
+ resumeRecording: () => void;
+ onRecordAvailable: (cb: (url: string) => void) => void;
+} {
+ const isRecordingSupported = computed(() => MediaRecorder.isTypeSupported('video/webm'));
+ const mediaRecorder = ref<MediaRecorder | null>(null);
+ const recordedChunks = ref<Blob[]>([]);
+ const recordAvailable = createEventHook();
+ const recordingState = ref<'stopped' | 'recording' | 'paused'>('stopped');
+
+ const startRecording = () => {
+ if (!isRecordingSupported.value) return;
+ if (!stream.value) return;
+ if (recordingState.value !== 'stopped') return;
+
+ mediaRecorder.value = new MediaRecorder(stream.value, { mimeType: 'video/webm' });
+
+ mediaRecorder.value.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ recordedChunks.value.push(e.data);
+ }
+ };
+
+ mediaRecorder.value.onstop = () => {
+ recordAvailable.trigger(createVideo());
+ };
+
+ if (mediaRecorder.value.state !== 'inactive') return;
+
+ mediaRecorder.value.start();
+ recordingState.value = 'recording';
+ };
+
+ const stopRecording = () => {
+ if (!isRecordingSupported.value) return;
+ if (!mediaRecorder.value) return;
+ if (recordingState.value === 'stopped') return;
+
+ mediaRecorder.value.stop();
+ recordingState.value = 'stopped';
+ };
+
+ const pauseRecording = () => {
+ if (!isRecordingSupported.value) return;
+ if (!mediaRecorder.value) return;
+ if (recordingState.value !== 'recording') return;
+
+ mediaRecorder.value.pause();
+ recordingState.value = 'paused';
+ };
+
+ const resumeRecording = () => {
+ if (!isRecordingSupported.value) return;
+ if (!mediaRecorder.value) return;
+
+ if (recordingState.value !== 'paused') return;
+
+ mediaRecorder.value.resume();
+ recordingState.value = 'recording';
+ };
+
+ const createVideo = () => {
+ const blob = new Blob(recordedChunks.value, { type: 'video/webm' });
+ const url = URL.createObjectURL(blob);
+ recordedChunks.value = [];
+ return url;
+ };
+
+ return {
+ isRecordingSupported,
+ startRecording,
+ stopRecording,
+ pauseRecording,
+ resumeRecording,
+ recordingState,
+
+ onRecordAvailable: recordAvailable.on,
+ };
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index f5aacc4..44ef8a3 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator';
+import { tool as cameraRecorder } from './camera-recorder';
import { tool as listConverter } from './list-converter';
import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter';
import { tool as jsonDiff } from './json-diff';
@@ -99,8 +100,8 @@ export const toolsByCategory: ToolCategory[] = [
],
},
{
- name: 'Images',
- components: [qrCodeGenerator, svgPlaceholderGenerator],
+ name: 'Images and videos',
+ components: [qrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
},
{
name: 'Development',
diff --git a/src/ui/c-alert/c-alert.demo.vue b/src/ui/c-alert/c-alert.demo.vue
new file mode 100644
index 0000000..5d8d1f2
--- /dev/null
+++ b/src/ui/c-alert/c-alert.demo.vue
@@ -0,0 +1,11 @@
+<template>
+ <c-alert v-for="variant in variants" :key="variant" :type="variant" mb-4>
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
+ quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
+ odio!
+ </c-alert>
+</template>
+
+<script lang="ts" setup>
+const variants = ['warning'] as const;
+</script>
diff --git a/src/ui/c-alert/c-alert.theme.ts b/src/ui/c-alert/c-alert.theme.ts
new file mode 100644
index 0000000..b974c37
--- /dev/null
+++ b/src/ui/c-alert/c-alert.theme.ts
@@ -0,0 +1,25 @@
+import { darken } from '../color/color.models';
+import { defineThemes } from '../theme/theme.models';
+import { appThemes } from '../theme/themes';
+
+// eslint-disable-next-line
+import WarningIcon from '~icons/mdi/alert-circle-outline';
+
+export const { useTheme } = defineThemes({
+ dark: {
+ warning: {
+ backgroundColor: appThemes.dark.warning.colorFaded,
+ borderColor: appThemes.dark.warning.color,
+ textColor: appThemes.dark.warning.color,
+ icon: WarningIcon,
+ },
+ },
+ light: {
+ warning: {
+ backgroundColor: appThemes.light.warning.colorFaded,
+ borderColor: appThemes.light.warning.color,
+ textColor: darken(appThemes.light.warning.color, 40),
+ icon: WarningIcon,
+ },
+ },
+});
diff --git a/src/ui/c-alert/c-alert.vue b/src/ui/c-alert/c-alert.vue
new file mode 100644
index 0000000..1fedbb0
--- /dev/null
+++ b/src/ui/c-alert/c-alert.vue
@@ -0,0 +1,32 @@
+<template>
+ <div class="c-alert" flex items-center b-rd-4px pa-5>
+ <div class="c-alert--icon" mr-4 text-40px op-60>
+ <slot name="icon">
+ <component :is="variantTheme.icon" />
+ </slot>
+ </div>
+
+ <div class="c-alert--content">
+ <slot />
+ </div>
+ </div>
+</template>
+
+<script lang="ts" setup>
+import { useTheme } from './c-alert.theme';
+
+const props = withDefaults(defineProps<{ type?: 'warning' }>(), { type: 'warning' });
+const { type } = toRefs(props);
+
+const theme = useTheme();
+const variantTheme = computed(() => theme.value[type.value]);
+</script>
+
+<style lang="less" scoped>
+.c-alert {
+ background-color: v-bind('variantTheme.backgroundColor');
+ color: v-bind('variantTheme.textColor');
+ font-size: inherit;
+ line-height: 20px;
+}
+</style>
diff --git a/src/ui/c-button/c-button.demo.vue b/src/ui/c-button/c-button.demo.vue
index ce339f5..48576f6 100644
--- a/src/ui/c-button/c-button.demo.vue
+++ b/src/ui/c-button/c-button.demo.vue
@@ -45,7 +45,7 @@
import _ from 'lodash';
const buttonVariants = ['basic', 'text'] as const;
-const buttonTypes = ['default', 'primary', 'warning'] as const;
+const buttonTypes = ['default', 'primary', 'warning', 'error'] as const;
const buttonSizes = ['small', 'medium', 'large'] as const;
</script>
diff --git a/src/ui/c-button/c-button.theme.ts b/src/ui/c-button/c-button.theme.ts
index 5b4c26f..e2e1591 100644
--- a/src/ui/c-button/c-button.theme.ts
+++ b/src/ui/c-button/c-button.theme.ts
@@ -61,6 +61,12 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
hoverBackground: lighten(theme.warning.colorFaded, 30),
pressedBackground: darken(theme.warning.colorFaded, 30),
}),
+ error: createState({
+ textColor: theme.error.color,
+ backgroundColor: theme.error.colorFaded,
+ hoverBackground: lighten(theme.error.colorFaded, 30),
+ pressedBackground: darken(theme.error.colorFaded, 30),
+ }),
},
text: {
default: createState({
@@ -81,6 +87,12 @@ const createTheme = ({ style }: { style: 'light' | 'dark' }) => {
hoverBackground: theme.warning.colorFaded,
pressedBackground: darken(theme.warning.colorFaded, 30),
}),
+ error: createState({
+ textColor: darken(theme.error.color, 20),
+ backgroundColor: 'transparent',
+ hoverBackground: theme.error.colorFaded,
+ pressedBackground: darken(theme.error.colorFaded, 30),
+ }),
},
};
};
diff --git a/src/ui/c-button/c-button.vue b/src/ui/c-button/c-button.vue
index 121a1e9..24b91b8 100644
--- a/src/ui/c-button/c-button.vue
+++ b/src/ui/c-button/c-button.vue
@@ -18,7 +18,7 @@ import { useAppTheme } from '../theme/themes';
const props = withDefaults(
defineProps<{
- type?: 'default' | 'primary' | 'warning';
+ type?: 'default' | 'primary' | 'warning' | 'error';
variant?: 'basic' | 'text';
disabled?: boolean;
round?: boolean;