feat(new tool): camera screenshot and recorder
This commit is contained in:
		
							parent
							
								
									8515c24264
								
							
						
					
					
						commit
						e1302037e6
					
				| @ -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, | ||||
|  | ||||
							
								
								
									
										16
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -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'] | ||||
|  | ||||
| @ -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" | ||||
|   /> | ||||
|  | ||||
							
								
								
									
										7
									
								
								src/modules/shared/date.models.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/modules/shared/date.models.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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'); | ||||
| } | ||||
							
								
								
									
										6
									
								
								src/shims.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								src/shims.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -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; | ||||
| } | ||||
|  | ||||
							
								
								
									
										202
									
								
								src/tools/camera-recorder/camera-recorder.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								src/tools/camera-recorder/camera-recorder.vue
									
									
									
									
									
										Normal file
									
								
							| @ -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 flex-1 v-if="currentMicrophone && microphones.length > 0"> | ||||
|           <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.slice().reverse()" :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.push({ 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.push({ 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> | ||||
							
								
								
									
										12
									
								
								src/tools/camera-recorder/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/camera-recorder/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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'), | ||||
| }); | ||||
							
								
								
									
										88
									
								
								src/tools/camera-recorder/useMediaRecorder.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/tools/camera-recorder/useMediaRecorder.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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, | ||||
|   }; | ||||
| } | ||||
| @ -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', | ||||
|  | ||||
							
								
								
									
										11
									
								
								src/ui/c-alert/c-alert.demo.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/ui/c-alert/c-alert.demo.vue
									
									
									
									
									
										Normal file
									
								
							| @ -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> | ||||
							
								
								
									
										25
									
								
								src/ui/c-alert/c-alert.theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/ui/c-alert/c-alert.theme.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
							
								
								
									
										32
									
								
								src/ui/c-alert/c-alert.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/ui/c-alert/c-alert.vue
									
									
									
									
									
										Normal file
									
								
							| @ -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> | ||||
| @ -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> | ||||
| 
 | ||||
|  | ||||
| @ -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), | ||||
|       }), | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user