diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/FormatTransformer.vue | 4 | ||||
-rw-r--r-- | src/modules/shared/date.models.ts | 7 | ||||
-rw-r--r-- | src/shims.d.ts | 6 | ||||
-rw-r--r-- | src/tools/camera-recorder/camera-recorder.vue | 202 | ||||
-rw-r--r-- | src/tools/camera-recorder/index.ts | 12 | ||||
-rw-r--r-- | src/tools/camera-recorder/useMediaRecorder.ts | 88 | ||||
-rw-r--r-- | src/tools/index.ts | 5 | ||||
-rw-r--r-- | src/ui/c-alert/c-alert.demo.vue | 11 | ||||
-rw-r--r-- | src/ui/c-alert/c-alert.theme.ts | 25 | ||||
-rw-r--r-- | src/ui/c-alert/c-alert.vue | 32 | ||||
-rw-r--r-- | src/ui/c-button/c-button.demo.vue | 2 | ||||
-rw-r--r-- | src/ui/c-button/c-button.theme.ts | 12 | ||||
-rw-r--r-- | src/ui/c-button/c-button.vue | 2 |
13 files changed, 402 insertions, 6 deletions
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; |