initial commit
This commit is contained in:
24
runninglog-frontend/.gitignore
vendored
Normal file
24
runninglog-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
runninglog-frontend/.vscode/extensions.json
vendored
Normal file
3
runninglog-frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
runninglog-frontend/README.md
Normal file
5
runninglog-frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
runninglog-frontend/index.html
Normal file
13
runninglog-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3545
runninglog-frontend/package-lock.json
generated
Normal file
3545
runninglog-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
runninglog-frontend/package.json
Normal file
25
runninglog-frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "runninglog-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"dayjs": "^1.11.13",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
1
runninglog-frontend/public/vite.svg
Normal file
1
runninglog-frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
28
runninglog-frontend/src/App.vue
Normal file
28
runninglog-frontend/src/App.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div id="app" class="w-full h-screen flex flex-col">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- App header -->
|
||||
<header class="h-12 border-b flex items-center px-4">
|
||||
App Header
|
||||
</header>
|
||||
|
||||
<!-- calendar area fills remaining space -->
|
||||
<main class="flex-1 overflow-hidden">
|
||||
<Calendar @request-load="onRequestLoad" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Calendar from './components/Calendar.vue'
|
||||
|
||||
function onRequestLoad(dates: string[]) {
|
||||
// Example: fetch events for `dates` from API
|
||||
console.log('request-load', dates)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* nothing special here */
|
||||
</style>
|
||||
1
runninglog-frontend/src/assets/vue.svg
Normal file
1
runninglog-frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
223
runninglog-frontend/src/components/Calendar.vue
Normal file
223
runninglog-frontend/src/components/Calendar.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="relative h-full overflow-y-auto border"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<!-- sticky header -->
|
||||
<div class="sticky top-0 z-10 border-b">
|
||||
<div class="grid grid-cols-7 text-center font-medium bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div v-for="day in days" :key="day" class="py-2 border-r last:border-r-0">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- top spacer -->
|
||||
<div :style="{ height: topSpacerHeight + 'px' }"></div>
|
||||
|
||||
<!-- weeks -->
|
||||
<div
|
||||
v-for="week in visibleWeeks"
|
||||
:key="week.id"
|
||||
class="grid grid-cols-7"
|
||||
role="row"
|
||||
:data-testid="'week-' + week.id"
|
||||
>
|
||||
<div
|
||||
v-for="(day, dayIndex) in week.days"
|
||||
class="h-30 flex items-center justify-center"
|
||||
:class="{
|
||||
// bottom
|
||||
'border-b-thick': borderStyles[week.id*DAYS_IN_WEEK + dayIndex].bottom,
|
||||
'border-b-regular': !borderStyles[week.id*DAYS_IN_WEEK + dayIndex].bottom,
|
||||
|
||||
// right
|
||||
'border-r-none': dayIndex !== 6,
|
||||
'border-r-regular': dayIndex === 6,
|
||||
|
||||
// left
|
||||
'border-l-thick': borderStyles[week.id*DAYS_IN_WEEK + dayIndex].left,
|
||||
'border-l-regular': !borderStyles[week.id*DAYS_IN_WEEK + dayIndex].left,
|
||||
|
||||
// top
|
||||
'border-t-none': true,
|
||||
|
||||
// backgrounds
|
||||
'bg-calendar-first-day': day.date() === 1,
|
||||
'bg-calendar': day.date() !== 1
|
||||
}"
|
||||
role="gridcell"
|
||||
:data-testid="'day-'+dayIndex"
|
||||
|
||||
>
|
||||
{{day.format("MMM DD YYYY")}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bottom spacer -->
|
||||
<div :style="{ height: bottomSpacerHeight + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
|
||||
// Emits type (keeps your existing API)
|
||||
const emit = defineEmits<{ (e: 'request-load', dates: string[]): void }>()
|
||||
|
||||
interface Week { id: number, days: Dayjs[] }
|
||||
|
||||
type MonthBorderInfo = {
|
||||
bottom: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
left: boolean;
|
||||
}
|
||||
|
||||
const scrollContainer = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const weekHeight = 120; // px (Tailwind h-20)
|
||||
const buffer = 5;
|
||||
const totalWeeks = 4_000;
|
||||
const DAYS_RAW: string[] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const DAYS_IN_WEEK = DAYS_RAW.length;
|
||||
|
||||
|
||||
const startDateApprox = ref<Dayjs>(dayjs("2010-01-04")); // Maybe we load this from app or elsewhere
|
||||
const loadDate = ref<Dayjs>(dayjs("2025-10-03")); // Maybe we load this from app or elsewhere
|
||||
const weekStartDay = ref<number>(1); // maybe we load this from user settings
|
||||
|
||||
function getStartOfWeek(date: Dayjs): Dayjs{
|
||||
const dayOfWeek = (date.get('day') - weekStartDay.value + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
||||
return date.subtract(dayOfWeek, 'day');
|
||||
}
|
||||
|
||||
const startDate = computed<Dayjs>(() => {
|
||||
return getStartOfWeek(startDateApprox.value);
|
||||
});
|
||||
|
||||
const loadIndex = computed<number>(() =>{
|
||||
const loadDateFirst = getStartOfWeek(loadDate.value);
|
||||
return loadDateFirst.diff(startDate.value, 'weeks');
|
||||
});
|
||||
|
||||
const days = computed<string[]>(() => {
|
||||
return DAYS_RAW.slice(weekStartDay.value, DAYS_IN_WEEK).concat(DAYS_RAW.slice(0, weekStartDay.value));
|
||||
});
|
||||
|
||||
const borderStyles = computed(() => {
|
||||
const styles: Record<number, MonthBorderInfo> = {}
|
||||
for (let weekIndex = 0; weekIndex < totalWeeks; weekIndex++) {
|
||||
for (let dayIndex = 0; dayIndex < DAYS_IN_WEEK; dayIndex++) {
|
||||
const totalDayIndex = weekIndex*7 + dayIndex;
|
||||
const day = startDate.value.add(totalDayIndex, 'day');
|
||||
styles[totalDayIndex] = {
|
||||
bottom: day.month() !== day.add(7, "day").month(),
|
||||
right: dayIndex < 6 && day.month() !== day.add(1, "day").month(),
|
||||
top: day.month() !== day.subtract(7, "day").month(),
|
||||
left: dayIndex > 0 && day.month() !== day.subtract(1, "day").month()
|
||||
}
|
||||
}
|
||||
}
|
||||
return styles
|
||||
})
|
||||
|
||||
const visibleCount = ref<number>(0)
|
||||
const scrollTop = ref<number>(0)
|
||||
|
||||
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function calculateVisibleCount() {
|
||||
if (!scrollContainer.value) return
|
||||
//console.log(scrollContainer.value.clientHeight);
|
||||
visibleCount.value =
|
||||
Math.ceil(scrollContainer.value.clientHeight / weekHeight) + buffer * 2
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
if (!scrollContainer.value) return
|
||||
console.log("scroll")
|
||||
scrollTop.value = scrollContainer.value.scrollTop
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!scrollContainer.value) return
|
||||
|
||||
// Observe size changes so visibleCount stays correct
|
||||
// resizeObserver = new ResizeObserver(() => {
|
||||
// console.log("resize")
|
||||
// calculateVisibleCount()
|
||||
// })
|
||||
// resizeObserver.observe(scrollContainer.value)
|
||||
|
||||
// initial visible count
|
||||
calculateVisibleCount()
|
||||
|
||||
// wait a tick then center the scroll position
|
||||
await nextTick()
|
||||
// center so we can scroll up and down
|
||||
scrollContainer.value.scrollTop =
|
||||
loadIndex.value * weekHeight - scrollContainer.value.clientHeight / 2
|
||||
// sync reactive scrollTop
|
||||
scrollTop.value = scrollContainer.value.scrollTop
|
||||
|
||||
// quick debug info (remove in production)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('calendar mounted', {
|
||||
clientHeight: scrollContainer.value.clientHeight,
|
||||
visibleCount: visibleCount.value,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver && scrollContainer.value) {
|
||||
resizeObserver.unobserve(scrollContainer.value)
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
const firstVisibleIndex = computed<number>(() =>
|
||||
Math.max(0, Math.floor(scrollTop.value / weekHeight) - buffer)
|
||||
)
|
||||
|
||||
const visibleWeeks = computed<Week[]>(() => {
|
||||
const weeks: Week[] = []
|
||||
for (let i = 0; i < visibleCount.value; i++) {
|
||||
const index = firstVisibleIndex.value + i
|
||||
if (index >= totalWeeks) break
|
||||
weeks.push({
|
||||
id: index,
|
||||
days: generateDays(index)
|
||||
})
|
||||
}
|
||||
return weeks
|
||||
})
|
||||
|
||||
function generateDays(weekIndex: number): Dayjs[]{
|
||||
const weekStart = startDate.value.add(weekIndex*DAYS_IN_WEEK, 'day');
|
||||
const ret: Dayjs[] = []
|
||||
for (let i = 0; i < DAYS_IN_WEEK; i++)
|
||||
{
|
||||
ret.push(weekStart.add(i, 'day'));
|
||||
}
|
||||
return ret;
|
||||
|
||||
}
|
||||
|
||||
const topSpacerHeight = computed<number>(() => firstVisibleIndex.value * weekHeight)
|
||||
|
||||
const bottomSpacerHeight = computed<number>(() =>
|
||||
Math.max(
|
||||
0,
|
||||
totalWeeks - (firstVisibleIndex.value + visibleWeeks.value.length)
|
||||
) * weekHeight
|
||||
)
|
||||
</script>
|
||||
4
runninglog-frontend/src/main.ts
Normal file
4
runninglog-frontend/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
createApp(App).mount('#app')
|
||||
67
runninglog-frontend/src/style.css
Normal file
67
runninglog-frontend/src/style.css
Normal file
@@ -0,0 +1,67 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #1e2942;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--calendar-first-day-bg: hsl(266, 57%, 14%); /* slightly darker background */
|
||||
--calendar-border-thick: 2px solid rgba(244, 240, 240, 0.8);
|
||||
--calendar-border-regular: 2px solid rgba(104, 93, 93, 0.8);
|
||||
--calendar-bg: rgb(51, 27, 82);
|
||||
}
|
||||
|
||||
.bg-calendar-first-day {
|
||||
background-color: var(--calendar-first-day-bg);
|
||||
}
|
||||
|
||||
.bg-calendar{
|
||||
background-color: var(--calendar-bg);
|
||||
}
|
||||
|
||||
.border-b-thick {
|
||||
border-bottom: var(--calendar-border-thick);
|
||||
}
|
||||
.border-r-none {
|
||||
border-right: none;
|
||||
}
|
||||
.border-t-none {
|
||||
border-top: none;
|
||||
}
|
||||
.border-l-thick {
|
||||
border-left: var(--calendar-border-thick);
|
||||
}
|
||||
|
||||
.border-b-regular {
|
||||
border-bottom: var(--calendar-border-regular);
|
||||
}
|
||||
.border-r-regular {
|
||||
border-right: var(--calendar-border-regular);
|
||||
}
|
||||
.border-t-regular {
|
||||
border-top: var(--calendar-border-regular);
|
||||
}
|
||||
.border-l-regular {
|
||||
border-left: var(--calendar-border-regular);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #16222d;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
runninglog-frontend/src/vite-env.d.ts
vendored
Normal file
1
runninglog-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
15
runninglog-frontend/tsconfig.app.json
Normal file
15
runninglog-frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
9
runninglog-frontend/tsconfig.json
Normal file
9
runninglog-frontend/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
|
||||
|
||||
}
|
||||
26
runninglog-frontend/tsconfig.node.json
Normal file
26
runninglog-frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"allowJs": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
runninglog-frontend/vite.config.ts
Normal file
10
runninglog-frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), vueDevTools()],
|
||||
})
|
||||
Reference in New Issue
Block a user