diff --git a/.env b/.env new file mode 100644 index 0000000..12006df --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_ROOT = "https://auth.zoe.rip" \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..196cf4c --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +VITE_ROOT = "127.0.0.1" \ No newline at end of file diff --git a/environment.d.ts b/environment.d.ts new file mode 100644 index 0000000..28bbc81 --- /dev/null +++ b/environment.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + VITE_ROOT : "127.0.0.1:8099" | "https://auth.zoe.rip" + } + } +} + +export {} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3c36ed5..200948f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "zoe-rip", "version": "0.0.0", "dependencies": { + "typescript": "^5.9.3", "vue": "^3.5.18", "vue-router": "^4.6.3" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", "vite": "^7.1.2" } }, @@ -941,6 +943,25 @@ "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", "license": "MIT" }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1181,10 +1202,23 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6075e92..39614a4 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "typescript": "^5.9.3", "vue": "^3.5.18", "vue-router": "^4.6.3" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", "vite": "^7.1.2" } } diff --git a/src/components/CRTContainer.vue b/src/components/CRTContainer.vue index c1208d6..962408c 100644 --- a/src/components/CRTContainer.vue +++ b/src/components/CRTContainer.vue @@ -9,17 +9,24 @@ + + + + \ No newline at end of file diff --git a/src/components/Counter.vue b/src/components/Counter.vue deleted file mode 100644 index 7b48658..0000000 --- a/src/components/Counter.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/components/CursorGlow.vue b/src/components/CursorGlow.vue index 80d7b8f..cc81035 100644 --- a/src/components/CursorGlow.vue +++ b/src/components/CursorGlow.vue @@ -13,5 +13,6 @@ width: 3rem; aspect-ratio: 1 / 1; border-radius: 100%; + pointer-events: none; } \ No newline at end of file diff --git a/src/components/LoginField.vue b/src/components/LoginField.vue new file mode 100644 index 0000000..8c7a681 --- /dev/null +++ b/src/components/LoginField.vue @@ -0,0 +1,169 @@ + + + + + \ No newline at end of file diff --git a/src/components/Navigation.vue b/src/components/Navigation.vue index 1d65799..a84bd0d 100644 --- a/src/components/Navigation.vue +++ b/src/components/Navigation.vue @@ -1,42 +1,46 @@ \ No newline at end of file diff --git a/src/composables/caret.ts b/src/composables/caret.ts new file mode 100644 index 0000000..418dda2 --- /dev/null +++ b/src/composables/caret.ts @@ -0,0 +1,306 @@ +import { ref, computed, nextTick, onMounted, onUnmounted, type Ref, watch } from "vue" + +export interface UseCaretOptions { + target: Ref + caret: Ref + thickness?: number + color?: string + blinkInterval?: number + hideNativeCaret?: boolean + useCanvasMeasure?: boolean + labelId? : string +} + +export interface UseCaretResult { + caretVisible: Ref + caretStyle: ReturnType + update: () => void + scheduleUpdate: () => void + startBlink: () => void + stopBlink: () => void + dispose: () => void +} + +export const useCaret = (options: UseCaretOptions): UseCaretResult => { + const { + target, caret, labelId, + thickness = 10, + color = "#42b983aa", + blinkInterval = 500, + hideNativeCaret = true, + useCanvasMeasure = false + } = options + + const caretVisible = ref(true) + const x = ref(0) + const y = ref(0) + const height = ref(16) + + let mirrorEl: HTMLDivElement | null = null + let blinkTimer: number | null = null + let listenersBound = false + let savedCaretColor: string | null = null + let isComposing = false + + const caretStyle = computed({ + get: () => ({ + position: "absolute", + width: "0px", + borderRight: `${thickness}px solid ${color}`, + left: `${x.value}px`, + top: `${y.value}px`, + height: `${height.value}px`, + opacity: caretVisible ? 1 : 0, + pointerEvents: "none" + }), set: () => {}}) + + const assertMirror = (): HTMLDivElement => { + if (mirrorEl) return mirrorEl + mirrorEl = document.createElement("div") + mirrorEl.style.position = "absolute" + mirrorEl.style.left = "-99999px" + mirrorEl.style.top = "-99999px" + mirrorEl.style.whiteSpace = "pre" + mirrorEl.style.visibility = "hidden" + mirrorEl.style.pointerEvents = "none" + document.body.appendChild(mirrorEl) + return mirrorEl + } + + const canvasMeasureWidth = (text: string, computedStyle: CSSStyleDeclaration): number => { + const canvas = document.createElement("canvas") + const cx = canvas.getContext("2d") + if (!cx) return 0 + const weight = computedStyle.fontWeight || "normal" + const size = computedStyle.fontSize || "1rem" + const family = computedStyle.fontFamily || "'Courier New', Courier, monospace" + const style = computedStyle.fontStyle || "normal" + cx.font = `${style} ${weight} ${size} ${family}` + return cx.measureText(text).width + } + + const measureInputCaret = (input: HTMLInputElement) => { + const selStart = input.selectionStart ?? 0 + const computedStyle = getComputedStyle(input) + + const borderTop = parseFloat(computedStyle.borderTopWidth || "0") || 0 + const paddingTop = parseFloat(computedStyle.paddingTop || "0") || 0 + const paddingBottom = parseFloat(computedStyle.paddingBottom || "0") || 0 + let lineH = parseFloat(computedStyle.lineHeight || "0") + if (!Number.isFinite(lineH) || !lineH) { + lineH = input.clientHeight - paddingTop - paddingBottom + } + height.value = Math.max(1, Math.round(lineH)) + y.value = Math.round(borderTop + paddingTop) + + const borderLeft = parseFloat(computedStyle.borderLeftWidth || "0") || 0 + const paddingLeft = parseFloat(computedStyle.paddingLeft || "0") || 0 + const paddingRight = parseFloat(computedStyle.paddingRight || "0") || 0 + const scrollLeft = input.scrollLeft || 0 // helps us stay in visual bounds + const inputOffsetLeft = input.offsetLeft + + const textUpToCaret = input.value.slice(0, selStart) + let textWidth = 0 + + if (useCanvasMeasure) { // less precise but i may be able to use it over the old mirror el method + textWidth = canvasMeasureWidth(textUpToCaret, computedStyle) + } else { + const mr = assertMirror() + mr.style.fontFamily = computedStyle.fontFamily + mr.style.fontSize = computedStyle.fontSize + mr.style.fontWeight = computedStyle.fontWeight + mr.style.fontStyle = computedStyle.fontStyle + mr.style.letterSpacing = computedStyle.letterSpacing + mr.style.textTransform = computedStyle.textTransform + mr.style.textRendering = "optimizeLegibility" + mr.textContent = textUpToCaret + textWidth = mr.offsetWidth + } + + let caretX = inputOffsetLeft + borderLeft + paddingLeft + textWidth - scrollLeft + + // clamp to visible area + const maxX = input.clientWidth - (parseFloat(computedStyle.borderRightWidth || "0") || 0) - paddingRight - 1 + const minX = borderLeft + paddingLeft + caretX = Math.max(minX, Math.min(maxX, caretX)) // + (labelId ? (document.getElementById(labelId)?.getBoundingClientRect().width || 0) + (parseFloat(caret.value?.style.marginLeft.replace("px", "") || "0") || 0) + 8 : 0) + + x.value = Math.round(caretX) + } + + const update = () => { + const el = target.value + const cr = caret.value + if (!el || !cr) return + if (isComposing) return // kinda debounce + + measureInputCaret(el) + } + + const scheduleUpdate = () => requestAnimationFrame(update) + const startBlink = () => caret.value?.classList.add("blinking") + const stopBlink = () => caret.value?.classList.remove("blinking") + + const bindListeners = (el: HTMLInputElement) => { + if (listenersBound) return + if (hideNativeCaret) { + const computedStyle = getComputedStyle(el) + savedCaretColor = computedStyle.caretColor || null; + (el.style as any).caretColor = "transparent" + } + + el.addEventListener("input", update) + el.addEventListener("keydown", scheduleUpdate) + el.addEventListener("click", update) + el.addEventListener("focus", () => { + startBlink() + scheduleUpdate() + }) + el.addEventListener("blur", () => stopBlink()) + + + // helps for IME input - otherwise it jitters + el.addEventListener('compositionstart', () => isComposing = true) + el.addEventListener('compositionend', () => { + isComposing = false + scheduleUpdate() + }) + + window.addEventListener("resize", update) + listenersBound = true + } + + const unbindListeners = (el: HTMLInputElement) => { + if (!listenersBound) return + + el.removeEventListener("input", update) + el.removeEventListener("keydown", scheduleUpdate) + el.removeEventListener("click", update) + el.removeEventListener("focus", startBlink as EventListener) + el.removeEventListener("blur", stopBlink as EventListener) + el.removeEventListener("compositionstart", () => {}) + el.removeEventListener("compositionend", () => {}) + window.removeEventListener("resize", update) + + // reset the native caret + if (hideNativeCaret) { + if (savedCaretColor !== null) { + (el.style as any).caretColor = savedCaretColor + } else (el.style as any).caretColor = "" + savedCaretColor = null + } + listenersBound = false + } + + const dispose = () => { + const el = target.value + if (el) unbindListeners(el) + if (blinkTimer) { + clearInterval(blinkTimer) + blinkTimer = null + } + if (mirrorEl && mirrorEl.parentNode) { + mirrorEl.parentNode.removeChild(mirrorEl) + mirrorEl = null + } + } + + // handle lifecycle + onMounted(() => { + nextTick(() => { + const el = target.value + if (!el) return + bindListeners(el) + scheduleUpdate() + }) + }) + onUnmounted(() => dispose()) + + // if the target ref changes, we need to rebind + watch(target, (newEl, oldEl) => { + if (oldEl) unbindListeners(oldEl) + if (newEl) { + bindListeners(newEl) + scheduleUpdate() + } + }) + + return { + caretVisible, + caretStyle, + update, + scheduleUpdate, + startBlink, + stopBlink, + dispose + } +} + + +/* old stuff +export const updateCaret = (inputEl: Ref, caretEl: Ref, caretX: Ref): FrameRequestCallback => { + const input = inputEl.value + const caret = caretEl.value + if (!input) console.log("input empty, returning") + if (!caret) console.log("caret empty, returning") + if (!input || !caret) return () => {} + + const selStart = input.selectionStart ?? 0 + + const computedStyles = getComputedStyle(input) + + // copy all styles that affect text width + const mirror = assertMirror(input) + mirror.style.fontFamily = computedStyles.fontFamily + mirror.style.fontSize = computedStyles.fontSize + mirror.style.fontWeight = computedStyles.fontWeight + mirror.style.fontStyle = computedStyles.fontStyle + mirror.style.letterSpacing = computedStyles.letterSpacing + mirror.style.textTransform = computedStyles.textTransform + mirror.style.textRendering = "optimizeLegibility" + mirror.textContent = input.value.slice(0, selStart) + + const textWidth = mirror.offsetWidth + + const paddingLeft = parseFloat(computedStyles.paddingLeft) || 0 + const borderLeft = parseFloat(computedStyles.borderLeftWidth) || 0 + const scrollLeft = input.scrollLeft || 0 + + let x = borderLeft + paddingLeft + textWidth - scrollLeft // calc native caret x position + + // keep caret inbounds + const paddingRight = parseFloat(computedStyles.paddingRight || "0") + const minX = borderLeft + paddingLeft + const maxX = input.clientWidth - paddingRight - 1 + x = Math.max(minX, Math.min(maxX, x)) + + caretX.value = x + + const caretTop = parseFloat(computedStyles.borderTopWidth || "0") + parseFloat(computedStyles.paddingTop || "0") // lol + const lineHeight = parseFloat(computedStyles.lineHeight) + const height = Number.isFinite(lineHeight) ? lineHeight : input.clientHeight - (parseFloat(computedStyles.paddingTop || "0") + parseFloat(computedStyles.paddingBottom || "0")) + + caretEl.value.style.top = `${caretTop}px` // still funny + caretEl.value.style.height = `${height}px` + caretEl.value.style.left = `${caretX.value}px` + return () => {} +} + +export const scheduleUpdate = (inputEl: Ref, caretEl: Ref, caretX: Ref) => requestAnimationFrame(updateCaret(inputEl, caretEl, caretX)) + +// blink +export const startBlink = (caretVisible: Ref | boolean, blinkTimer: number) => { + caretVisible = true + if (blinkTimer) clearInterval(blinkTimer) + blinkTimer = setInterval(() => (caretVisible = !caretVisible), 500) +} + +export const stopBlink = (caretVisible: Ref | boolean, blinkTimer: number | null) => { + if (blinkTimer) clearInterval(blinkTimer) + blinkTimer = null + caretVisible = false +} + +export const caretDynamicStyle = (caretVisible: Ref | boolean) => ({ + opacity: caretVisible ? 1 : 0, +}) +*/ \ No newline at end of file diff --git a/src/composables/localApi.ts b/src/composables/localApi.ts new file mode 100644 index 0000000..039db1d --- /dev/null +++ b/src/composables/localApi.ts @@ -0,0 +1,40 @@ +export interface ApiParameters { + method?: string + headers?: Headers + body?: string +} + +async function callApi(endpoint: string, params: ApiParameters): Promise { + params.headers ? params.headers.set("Content-Type", "application/json") : params.headers = new Headers({"Content-Type": "application/json"}) + const res = await fetch(`${import.meta.env.VITE_BASE_URL || "http://localhost:8099"}${endpoint}`, params) + + if (!res.ok) throw res + const js = setTimeout(async () => await res.json(), 100) as T + return js // adds some frontend latency to slow down call frequency +} + +export const register = async (username: string, email: string, password: string, role: string = "user") => { + const registrationObj = { + username: username, + email: email, + password: password, + role: role + } + + return await callApi<{ message: string }>("/api/register", { + method: "post", + body: JSON.stringify(registrationObj) + }) +} + +export const login = async (username: string, password: string) => { + const credsObj = { + username: username, + password: password + } + + return await callApi<{ token: string, expiration: string}>("/api/token", { + method: "post", + body: JSON.stringify(credsObj) + }) +} \ No newline at end of file diff --git a/src/composables/typewriter.ts b/src/composables/typewriter.ts new file mode 100644 index 0000000..689312f --- /dev/null +++ b/src/composables/typewriter.ts @@ -0,0 +1,23 @@ +export const typeEffect = (selector: string, charsPerSecond: number = 20, delay: number = 6) => { + const targets = document.querySelectorAll(`${selector}:not(.shown)`) + Array.from(targets).forEach((el: Element) => { + const charCount = el.innerHTML.length + const typeDuration = charCount / charsPerSecond + const styleString = `animation: type ${typeDuration}s steps(${charCount}, end) ${delay}s, grow 1ms steps(1, end) ${delay}s;` + el.setAttribute("style", styleString) + setTimeout(() => el.classList.add("shown"), delay * 1001) + delay += typeDuration + }) +} + +export const backspaceEffect = (selector: string, charsPerSecond: number = 20, delay: number = 6) => { + const targets = document.querySelectorAll(`${selector}.shown`) + Array.from(targets).forEach((el: Element) => { + const charCount = el.innerHTML.length + const typeDuration = charCount / charsPerSecond + const styleString = `animation: backspace ${typeDuration}s steps(${charCount}, end) ${delay}s, shrink 1ms steps(1, end) ${delay}s;` + el.setAttribute("style", styleString) + setTimeout(() => el.classList.remove("shown"), delay * 1001) + delay += typeDuration + }) +} \ No newline at end of file diff --git a/src/pages/About.vue b/src/pages/About.vue index 101ad24..e2acecc 100644 --- a/src/pages/About.vue +++ b/src/pages/About.vue @@ -1,12 +1,12 @@ - \ No newline at end of file diff --git a/src/pages/Projects.vue b/src/pages/Projects.vue index ee404e7..4594e52 100644 --- a/src/pages/Projects.vue +++ b/src/pages/Projects.vue @@ -8,14 +8,14 @@ - +