aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
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;