This commit is contained in:
2025-05-24 01:49:48 +09:00
commit 62abbcf4eb
2376 changed files with 325522 additions and 0 deletions

File diff suppressed because one or more lines are too long

106
src/composables/darkmode.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { Plugin } from 'vue'
interface DarkmodeContext {
isDark: Ref<boolean>
onChange: (event: MouseEvent) => Promise<void>
}
type DarkModeSchema = 'auto' | 'dark' | 'light'
const darkmodeClass = 'is-dark'
const injectionKey = Symbol('darkmode') as InjectionKey<DarkmodeContext>
export const useDarkmode = () => {
return inject(injectionKey, {
// default values to prevent inject errors
isDark: ref(false),
onChange: async () => {},
})
}
export function createDarkmode(): Plugin {
return {
install(app) {
const preferredDark = usePreferredDark()
const colorSchema = useStorage<DarkModeSchema>('color-schema', 'auto')
const enableTransitions = () =>
!import.meta.env.SSR
&& 'startViewTransition' in document
&& window.matchMedia('(prefers-reduced-motion: no-preference)').matches
const isDark = computed({
get() {
return colorSchema.value === 'auto'
? preferredDark.value
: colorSchema.value === 'dark'
},
set(v: boolean) {
// disable transitions
if (!import.meta.env.SSR && document.documentElement) {
document.documentElement.classList.add('no-transition')
}
if (v === preferredDark.value) colorSchema.value = 'auto'
else colorSchema.value = v ? 'dark' : 'light'
if (!import.meta.env.SSR && document.documentElement) {
setTimeout(() => {
document.documentElement.classList.remove('no-transition')
}, 0)
}
},
})
const onChange = async (event: MouseEvent) => {
const target = event.target as HTMLInputElement
if (!enableTransitions()) {
isDark.value = !isDark.value
return
}
event.preventDefault()
const clipPath = [
`circle(0px at ${event.clientX}px ${event.clientY}px)`,
`circle(${Math.hypot(
Math.max(event.clientX, target.clientWidth - event.clientX),
Math.max(event.clientY, target.clientHeight - event.clientY),
)}px at ${event.clientX}px ${event.clientY}px)`,
]
await (document as any).startViewTransition(async () => {
isDark.value = !isDark.value
await nextTick()
}).ready
document.documentElement.animate(
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
{
duration: 300,
easing: 'ease-in',
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`,
},
)
}
if (!import.meta.env.SSR) {
watch(isDark, (value) => {
const body = document.documentElement
if (value) {
body.classList.add(darkmodeClass)
}
else {
body.classList.remove(darkmodeClass)
}
}, { immediate: true })
}
app.provide(injectionKey, {
isDark,
onChange,
})
},
}
}

View File

@@ -0,0 +1,40 @@
import type { Ref } from 'vue'
export interface DropdownOptions {
clickOutside?: boolean
}
/**
* Generate refs to handle a dropdown state
*/
export function useDropdownContext(
target: Ref<HTMLElement | undefined>,
options: DropdownOptions = { clickOutside: true },
) {
const isOpen = ref(false)
if (options.clickOutside) {
onClickOutside(target, () => {
isOpen.value = false
})
}
const open = () => {
isOpen.value = true
}
const close = () => {
isOpen.value = false
}
const toggle = () => {
isOpen.value = !isOpen.value
}
return reactive({
isOpen,
open,
close,
toggle,
})
}

20
src/composables/fetch.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { H3Event } from 'h3'
import { ofetch } from 'ofetch'
export function useApiFetch(event?: H3Event) {
const token = useUserToken(event)
return ofetch.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080',
// We set an interceptor for each request to
// include Bearer token to the request if user is logged in
onRequest: ({ options }) => {
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`,
}
}
},
})
}

View File

@@ -0,0 +1,62 @@
import type { MaybeRefOrGetter, InjectionKey } from 'vue'
import { useField, type FieldContext } from 'vee-validate'
import { defu } from 'defu'
export type VFieldContext = ReturnType<typeof createVFieldContext>
export const useVFieldSymbolContext = Symbol() as InjectionKey<VFieldContext>
function createVFieldContext<TValue = unknown>(id?: MaybeRefOrGetter<string>) {
const internal = ref(toValue(id))
const field = ref<FieldContext<TValue>>()
watch(
() => toValue(id),
(value) => {
internal.value = value || `v-field-${crypto.randomUUID()}`
},
)
if (id) {
field.value = useField(id)
}
const vFieldContext = {
id: internal,
field,
}
provide(useVFieldSymbolContext, vFieldContext)
return vFieldContext
}
interface VFieldContextOption {
id?: MaybeRefOrGetter<string>
create?: MaybeRefOrGetter<boolean>
inherit?: MaybeRefOrGetter<boolean>
help?: MaybeRefOrGetter<string>
}
export function useVFieldContext(options = {} as VFieldContextOption) {
const _options = defu(options, {
create: true,
inherit: true,
})
if (unref(_options.inherit)) {
const vFieldContext = inject(useVFieldSymbolContext, undefined)
if (vFieldContext) {
return vFieldContext
}
}
const _help = unref(_options.help) ? unref(_options.help) + ': ' : ''
if (!unref(_options.create)) {
throw new Error(
`${_help}useVFieldContext (create = false) must be used inside a VField component`,
)
}
return createVFieldContext(_options.id)
}

View File

@@ -0,0 +1,59 @@
import type { Ref } from 'vue'
type UseImageErrorOptions = {
fallback?: string
} | {
width?: number
height?: number
}
export function useImageError(target?: Ref<HTMLImageElement | null>, options: UseImageErrorOptions = {}) {
const handler = (event: Event) => {
if ('fallback' in options) {
onceError(event, options.fallback)
}
else if ('width' in options) {
onceError(event, options.width, options.height)
}
}
const cleanup = () => {
if (!target?.value) return
target.value?.removeEventListener('error', handler)
}
const stopWatch = watchEffect(() => {
if (!target?.value) return
cleanup()
target.value?.addEventListener('error', handler, {
once: true,
passive: true,
})
})
onUnmounted(() => {
stopWatch()
cleanup()
})
return {
onceError,
}
}
function onceError(event: Event, fallback?: string): void
function onceError(event: Event, width?: number, height?: number): void
function onceError(event: Event, width?: number | string, height?: number): void {
const target = event.target as HTMLImageElement
if (!target || !width) return
let src: string
if (typeof width === 'string') {
src = width
}
else {
src = `https://via.placeholder.com/${width}x${height ?? width}`
}
target.src = src
}

View File

@@ -0,0 +1,38 @@
import type { Ref } from 'vue'
const HEADER_SELECTORS = [
'h1[id]',
'h2[id]',
'h3[id]',
'h4[id]',
'h5[id]',
'h6[id]',
'a[name]',
]
export type TocItem = {
id: string
title: string
level: number
}
export function useMarkdownToc(target: Ref<HTMLElement | undefined>) {
const toc = ref<TocItem[]>([])
watchEffect(() => {
if (target.value) {
const anchors = target.value.querySelectorAll(HEADER_SELECTORS.join(', '))
anchors.forEach((anchor) => {
if (anchor.classList.contains('toc-ignore')) return
toc.value.push({
id: anchor.id,
level: parseInt(anchor.tagName.replace(/[a-z]+/i, '') || '1'),
title: anchor.textContent || '',
})
})
}
})
return toc
}

238
src/composables/notyf.ts Normal file
View File

@@ -0,0 +1,238 @@
import type { INotyfNotificationOptions, Notyf, NotyfNotification } from 'notyf'
import type { Plugin } from 'vue'
interface NotyfContext {
dismiss: (notification: NotyfNotification) => void
dismissAll: () => void
success: (payload: string | Partial<INotyfNotificationOptions>) => void
error: (payload: string | Partial<INotyfNotificationOptions>) => void
info: (payload: string | Partial<INotyfNotificationOptions>) => void
warning: (payload: string | Partial<INotyfNotificationOptions>) => void
primary: (payload: string | Partial<INotyfNotificationOptions>) => void
purple: (payload: string | Partial<INotyfNotificationOptions>) => void
blue: (payload: string | Partial<INotyfNotificationOptions>) => void
green: (payload: string | Partial<INotyfNotificationOptions>) => void
orange: (payload: string | Partial<INotyfNotificationOptions>) => void
}
export const useNotyf = () => {
return inject(notyfSymbol)!
}
const notyfSymbol: InjectionKey<NotyfContext>
= Symbol('notyf')
export function createNotyf(): Plugin {
return {
async install(app) {
const themeColors = useThemeColors()
let notyf: Notyf
if (!import.meta.env.SSR) {
const { Notyf } = await import('notyf')
notyf = new Notyf({
duration: 2000,
position: {
x: 'right',
y: 'bottom',
},
types: [
{
type: 'warning',
background: themeColors.warning,
icon: {
className: 'fas fa-hand-paper',
tagName: 'i',
text: '',
},
},
{
type: 'info',
background: themeColors.info,
icon: {
className: 'fas fa-info-circle',
tagName: 'i',
text: '',
},
},
{
type: 'primary',
background: themeColors.primary,
icon: {
className: 'fas fa-car-crash',
tagName: 'i',
text: '',
},
},
{
type: 'accent',
background: themeColors.purple,
icon: {
className: 'fas fa-car-crash',
tagName: 'i',
text: '',
},
},
{
type: 'purple',
background: themeColors.purple,
icon: {
className: 'fas fa-check',
tagName: 'i',
text: '',
},
},
{
type: 'blue',
background: themeColors.blue,
icon: {
className: 'fas fa-check',
tagName: 'i',
text: '',
},
},
{
type: 'green',
background: themeColors.lime,
icon: {
className: 'fas fa-check',
tagName: 'i',
text: '',
},
},
{
type: 'orange',
background: themeColors.orange,
icon: {
className: 'fas fa-check',
tagName: 'i',
text: '',
},
},
],
})
}
const context = {
dismiss: (notification: NotyfNotification) => {
notyf?.dismiss(notification)
},
dismissAll: () => {
notyf?.dismissAll()
},
success: (payload: string | Partial<INotyfNotificationOptions>) => {
return notyf?.success(payload)
},
error: (payload: string | Partial<INotyfNotificationOptions>) => {
return notyf?.error(payload)
},
info: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'info',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
warning: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'warning',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
primary: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'primary',
icon: {
className: 'lnir lnir-checkmark-circle',
tagName: 'i',
color: '#fff',
text: '',
},
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
purple: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'purple',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
blue: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'blue',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
green: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'green',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
orange: (payload: string | Partial<INotyfNotificationOptions>) => {
const options: Partial<INotyfNotificationOptions> = {
type: 'orange',
}
if (typeof payload === 'string') {
options.message = payload
}
else {
Object.assign(options, payload)
}
return notyf?.open(options)
},
} satisfies NotyfContext
app.provide(notyfSymbol, context)
},
}
}

View File

@@ -0,0 +1,24 @@
/**
* This is a store that hold responsive state
*
* Using useMediaQuery from @vueuse/core allow to bind
* css media queries results to ref
*
* We can import and use isLargeScreen, isMediumScreen anywhere in our project
* @see /src/components/navigation/LandingNavigation.vue
* @see /src/state/activeNavbarState.ts
*/
import { useMediaQuery } from '@vueuse/core'
export function useScreenSize() {
const isLargeScreen = useMediaQuery('(width >= 1024px)')
const isMediumScreen = useMediaQuery('(width >= 768px)')
const isMobileScreen = useMediaQuery('(width <= 767px)')
return {
isLargeScreen,
isMediumScreen,
isMobileScreen,
}
}

View File

@@ -0,0 +1,35 @@
import { useCssVar } from '@vueuse/core'
export const useThemeColors = () => {
const primary = useCssVar('--primary')
const secondary = useCssVar('--secondary')
const success = useCssVar('--success')
const info = useCssVar('--info')
const warning = useCssVar('--warning')
const danger = useCssVar('--danger')
const purple = useCssVar('--purple')
const blue = useCssVar('--blue')
const green = useCssVar('--green')
const yellow = useCssVar('--yellow')
const orange = useCssVar('--orange')
const lime = useCssVar('--lime')
const pink = useCssVar('--pink')
const grey = useCssVar('--muted-grey')
return reactive({
primary,
secondary,
success,
info,
warning,
danger,
purple,
blue,
green,
yellow,
orange,
lime,
pink,
grey,
})
}

View File

@@ -0,0 +1,30 @@
import type { TinySliderInstance, TinySliderSettings } from 'tiny-slider/src/tiny-slider'
import type { MaybeRefOrGetter } from 'vue'
export function useTinySlider(
target: Ref<Element | undefined>,
settings: MaybeRefOrGetter<Omit<TinySliderSettings, 'container'>> = {},
) {
const slider = shallowRef<TinySliderInstance | null>(null)
onMounted(async () => {
if (target.value) {
const { tns } = await import('tiny-slider/src/tiny-slider')
slider.value = tns({
container: target.value,
...toValue(settings),
})
}
})
onUnmounted(() => {
if (slider.value) {
slider.value.destroy()
}
})
return {
slider,
}
}

View File

@@ -0,0 +1,96 @@
import type { H3Event } from 'h3'
import { deleteCookie, getCookie, setCookie } from 'h3'
import { useCookies } from '@vueuse/integrations/useCookies'
import { useSSRContext } from 'vue'
import { type VueroSSRContext } from '/@server/types'
interface CookieOption {
path?: string
expires?: Date
maxAge?: number
domain?: string
secure?: boolean
httpOnly?: boolean
sameSite?: boolean | 'none' | 'lax' | 'strict'
partitioned?: boolean
}
const tokenKey = 'token'
const options = {
maxAge: 60 * 60 * 24 * 7,
path: '/',
sameSite: 'lax',
secure: true,
} satisfies CookieOption
export function useUserToken(event?: H3Event) {
let token: Ref<string | undefined>
// when client only, use localStorage
if (!__VUERO_SSR_BUILD__) {
token = useSessionStorage(tokenKey, '')
}
// otherwise, we need to use cookies to share the token between client and server
else {
// server side: cookies are managed by http headers.
// use h3 helpers and ssr context to get h3 event
if (import.meta.env.SSR) {
const _event = event || useSSRContext<VueroSSRContext>()?.event
token = computed<string | undefined>({
get() {
if (_event) {
return getCookie(_event, tokenKey)
}
},
set(value?: string) {
if (_event) {
if (value) {
setCookie(_event, tokenKey, value, options)
}
else {
deleteCookie(_event, tokenKey, options)
}
}
},
})
}
// client side, use cookies via document.cookie
else {
const cookies = useCookies(['locale'])
token = ref(cookies.get(tokenKey))
// we need to listen to changes in cookies in case it's changed from another tab
// or another part of the app
const listener = (event: any) => {
if (event.name === tokenKey) {
token.value = event.value
}
}
cookies.addChangeListener(listener)
// in case this composable is used inside a component that is unmounted
// we need to remove the listener to avoid memory leaks
if (getCurrentScope()) {
onScopeDispose(() => cookies?.removeChangeListener(listener))
}
// watch the token value and update the cookie if needed
watch(token, (value) => {
if (value && cookies.get(tokenKey) !== value) {
cookies.set(tokenKey, value, options)
}
else if (!value && cookies.get(tokenKey)) {
cookies.remove(tokenKey, options)
}
})
}
}
return token
}

View File

@@ -0,0 +1,35 @@
import type { Plugin } from 'vue'
export interface VueroContext extends Record<string, any> {}
const injectionKey = Symbol('vuero-context') as InjectionKey<VueroContext>
export function useVueroContext<T>(
key: string,
defaultValue?: () => T,
): Ref<T | null> {
const context = inject(injectionKey)
if (!context) {
throw new Error('useVueroContext() was called without having vuero-context plugin installed.')
}
const state = toRef(context, key)
if (state.value === undefined) {
const val = defaultValue?.()
state.value = val
}
return state
}
export function createVueroContext(
context = {} as VueroContext,
): Plugin {
return {
install(app) {
app.provide(injectionKey, reactive(context))
},
}
}

120
src/composables/wizard.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { InjectionKey } from 'vue'
/**
* Using typescript types allow better developer experience
* with autocompletion and compiler error prechecking
*/
import type { WizardData } from '/@src/types/wizard'
interface WizardStepOptions {
number: number
canNavigate?: boolean
previousStepFn?: () => Promise<void>
validateStepFn?: () => Promise<void>
}
export type WizardContext = ReturnType<typeof createWizardContext>
export const useWizardSymbolContext = Symbol('wizard') as InjectionKey<WizardContext>
function createWizardContext() {
const step = ref(1)
const loading = ref(false)
const canNavigate = ref(false)
const previousStepFn = shallowRef<WizardStepOptions['previousStepFn'] | null>()
const validateStepFn = shallowRef<WizardStepOptions['validateStepFn'] | null>()
const data = reactive<WizardData>({
name: '',
description: '',
relatedTo: 'UI/UX Design',
logo: null,
timeFrame: ref({
start: new Date(),
end: new Date(),
}),
budget: '< 5K',
attachments: [],
teammates: [],
tools: [],
customer: null,
})
const stepTitle = computed(() => {
switch (step.value) {
case 2:
return 'Project Info'
case 3:
return 'Project Details'
case 4:
return 'Project Files'
case 5:
return 'Team Members'
case 6:
return 'Project Tools'
case 7:
return 'Preview'
case 8:
return 'Finish'
case 1:
default:
return 'Project Type'
}
})
function setLoading(value: boolean) {
loading.value = value
}
function setStep(options?: WizardStepOptions) {
step.value = options?.number || 1
canNavigate.value = options?.canNavigate ?? false
previousStepFn.value = options?.previousStepFn ?? null
validateStepFn.value = options?.validateStepFn ?? null
}
async function save() {
loading.value = true
// simulate saving data
await sleep(2000)
loading.value = false
}
function reset() {
data.name = ''
data.description = ''
data.relatedTo = 'UI/UX Design'
data.logo = null
data.timeFrame = {
start: new Date(),
end: new Date(),
}
data.budget = '< 5K'
data.attachments = []
data.teammates = []
data.tools = []
data.customer = null
}
return reactive({
canNavigate,
previousStepFn,
validateStepFn,
step,
loading,
stepTitle,
data,
setLoading,
setStep,
save,
reset,
})
}
export function useWizard() {
let context = inject(useWizardSymbolContext, undefined)
if (!context) {
context = createWizardContext()
provide(useWizardSymbolContext, context)
}
return context
}