diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json index 363273af..744e14d2 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 40b805a9..f4d98ac8 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 5b187b2b..96f77988 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 00000000..37cdbade --- /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 f8798e57..fc76e210 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; + 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 00000000..81fec42c --- /dev/null +++ b/src/tools/camera-recorder/camera-recorder.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/tools/camera-recorder/index.ts b/src/tools/camera-recorder/index.ts new file mode 100644 index 00000000..3c5d11bd --- /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 00000000..eed0edda --- /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 }): { + isRecordingSupported: Ref; + 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(null); + const recordedChunks = ref([]); + 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 f5aacc4d..44ef8a3f 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 00000000..5d8d1f2d --- /dev/null +++ b/src/ui/c-alert/c-alert.demo.vue @@ -0,0 +1,11 @@ + + + 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 00000000..b974c374 --- /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 00000000..1fedbb0b --- /dev/null +++ b/src/ui/c-alert/c-alert.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/ui/c-button/c-button.demo.vue b/src/ui/c-button/c-button.demo.vue index ce339f5e..48576f6a 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; diff --git a/src/ui/c-button/c-button.theme.ts b/src/ui/c-button/c-button.theme.ts index 5b4c26f7..e2e1591f 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 121a1e96..24b91b84 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;