Initial commit

This commit is contained in:
Jonathan Bourdon
2024-08-14 11:19:26 -04:00
commit 4b21101fa5
58 changed files with 8241 additions and 0 deletions

15
Tasker.Ui/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

30
Tasker.Ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

7
Tasker.Ui/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

45
Tasker.Ui/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Tasker.Ui
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
Tasker.Ui/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

16
Tasker.Ui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Vuetify 3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6804
Tasker.Ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
Tasker.Ui/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "tasker-ui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@vueuse/core": "^10.11.0",
"axios": "^1.6.7",
"pinia": "^2.1.7",
"uuidv7": "^1.0.1",
"vue": "^3.4.29",
"vue-router": "^4.3.3",
"vuetify": "^3.5.6"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.14.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.1.0",
"npm-run-all2": "^6.2.0",
"postcss": "^8.4.41",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.9",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vite-plugin-vue-devtools": "^7.3.1",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.21"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

100
Tasker.Ui/src/App.vue Normal file
View File

@@ -0,0 +1,100 @@
<template>
<v-app>
<v-layout>
<v-system-bar app
color="secondary">
</v-system-bar>
<v-navigation-drawer
app
v-model="drawer"
:rail="rail"
permanent
@click="rail = !rail"
>
<v-list-item
prepend-avatar="https://randomuser.me/api/portraits/men/85.jpg"
title="John Leider"
>
</v-list-item>
<v-divider></v-divider>
<v-list density="compact" nav>
<v-list-item prepend-icon="mdi-home-city"
title="Inbox"
value="home"
></v-list-item>
<v-list-item prepend-icon="mdi-account"
title="Today"
value="account"
></v-list-item>
<v-list-item prepend-icon="mdi-account-group-outline"
title="Upcoming"
value="users"
></v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar app
color="primary"
prominent>
<v-app-bar-nav-icon variant="text"
@click.stop="drawer = !drawer">
</v-app-bar-nav-icon>
<v-toolbar-title>
My files
</v-toolbar-title>
</v-app-bar>
<v-main>
<v-container fluid>
<router-view></router-view>
</v-container>
</v-main>
<v-footer app class="flex bg-amber font-mono justify-center">
&copy;2024 Tasker
</v-footer>
</v-layout>
</v-app>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const drawer = ref(true)
const rail = ref(false)
const group = ref(null)
const items = ref([
{
title: 'Foo',
value: 'foo'
},
{
title: 'Bar',
value: 'bar'
},
{
title: 'Fizz',
value: 'fizz'
},
{
title: 'Buzz',
value: 'buzz'
}
])
watch(group, () => {
drawer.value = false
})
</script>

View File

@@ -0,0 +1,120 @@
import { ref, onMounted } from 'vue'
export interface Task {
id: string;
name: string; // [MaxLength(255)]
description?: string | null; // [MaxLength(2048)]
dueDate?: string | null;
}
export interface TaskApiClient {
getTasks(): Promise<Task[]>;
addTask(task: Task): Promise<Task>;
}
export class TaskApiClientImpl implements TaskApiClient {
private readonly baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async getTasks(): Promise<Task[]> {
const response = await fetch(`${this.baseUrl}/tasks`)
if (!response.ok) {
throw new Error('Failed to fetch tasks')
}
const data = await response.json()
return data as Task[]
}
async addTask(task: Task): Promise<Task> {
const response = await fetch(`${this.baseUrl}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(task)
})
if (!response.ok) {
throw new Error('Failed to add task')
}
const data = await response.json()
return data as Task
}
async completeTask(id: string): Promise<Task> {
const response = await fetch(`${this.baseUrl}/tasks/${id}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
'CompleteOn': new Date().toISOString()
})
})
if (!response.ok) {
throw new Error('Failed to add task')
}
const data = await response.json()
return data as Task
}
}
export function useTasks() {
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const taskApiClient = new TaskApiClientImpl('https://localhost:7055')
const fetchTasks = async () => {
isLoading.value = true
error.value = null
try {
tasks.value = await taskApiClient.getTasks()
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const addTask = async (task: Task) => {
isLoading.value = true
error.value = null
try {
const newTask = await taskApiClient.addTask(task)
tasks.value.push(newTask)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
const completeTask = async (id: string) => {
isLoading.value = true
error.value = null
try {
await taskApiClient.completeTask(id)
} catch (err) {
error.value = (err as Error).message
} finally {
isLoading.value = false
}
}
onMounted(fetchTasks)
return {
tasks,
isLoading,
error,
fetchTasks,
addTask,
completeTask
}
}

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,35 @@
/*@import './base.css';*/
/*#app {*/
/* max-width: 1280px;*/
/* margin: 0 auto;*/
/* padding: 2rem;*/
/* font-weight: normal;*/
/*}*/
/*a,*/
/*.green {*/
/* text-decoration: none;*/
/* color: hsla(160, 100%, 37%, 1);*/
/* transition: 0.4s;*/
/* padding: 3px;*/
/*}*/
/*@media (hover: hover) {*/
/* a:hover {*/
/* background-color: hsla(160, 100%, 37%, 0.2);*/
/* }*/
/*}*/
/*@media (min-width: 1024px) {*/
/* body {*/
/* display: flex;*/
/* place-items: center;*/
/* }*/
/* #app {*/
/* display: grid;*/
/* grid-template-columns: 1fr 1fr;*/
/* padding: 0 2rem;*/
/* }*/
/*}*/

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
import { type Task, useTasks } from '@/api/taskApi'
import { uuidv7 } from 'uuidv7'
const name = ref('')
const description = ref(null)
const dueDate = ref(null)
const { addTask } = useTasks()
const doAddTask = () => {
const newTask: Task = {
id: uuidv7(),
name: name.value,
description: description.value,
dueDate: dueDate.value
}
addTask(newTask)
}
const cancelAddTask = () => {
}
</script>
<template>
<v-text-field label="Task name"
v-model="name"
variant="underlined">
</v-text-field>
<v-text-field label="Description"
v-model="description"
variant="underlined">
</v-text-field>
<div class="flex flex-row space-x-2">
<span>due-date</span>
<span>priority</span>
<span>tags</span>
<span>reminders</span>
</div>
<div class=" bg-teal-400 p-2 flex flex-row space-x-2 justify-end">
<span class="justify-start">projects</span>
<div class="bg-red space-x-2 justify-end">
<v-btn @click="cancelAddTask()"
variant="outlined">
Cancel
</v-btn>
<v-btn @click="doAddTask()"
color="primary"
variant="flat">
Add Task
</v-btn>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { useTasks } from '@/api/taskApi'
const { completeTask, tasks, isLoading, error } = useTasks()
function itemCompleted(id: string) {
console.log(`completing task: ${id}`)
completeTask(id)
}
</script>
<template>
<v-list v-if="!isLoading && !error">
<v-list-item v-for="task in tasks" :key="task.id">
<div class="flex p-1 bg-amber">
<div>
<v-checkbox-btn @change="itemCompleted(task.id)"
></v-checkbox-btn>
</div>
<div class="flex-column">
<div class="">
<div class="font-sans">{{ task.name }}</div>
<div class="font-serif font-normal">Description {{ task.description }}</div>
</div>
<div>{{ task.dueDate }}</div>
<!-- <div>{{ task.project.Name }}</div>-->
</div>
</div>
</v-list-item>
</v-list>
<v-alert v-else-if="error" type="error">
{{ error }}
</v-alert>
<v-progress-circular v-else indeterminate></v-progress-circular>
</template>
<style scoped>
</style>

4
Tasker.Ui/src/index.css Normal file
View File

@@ -0,0 +1,4 @@
/* ./src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

18
Tasker.Ui/src/main.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import vuetify from './vuetify'
import 'vuetify/styles' // Ensure this line is present
import '@mdi/font/css/materialdesignicons.css' // Import the Material Design Icons CSS
import './assets/main.css'
import './index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(vuetify)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
}
]
})
export default router

View File

@@ -0,0 +1,12 @@
import {ref, computed} from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,16 @@
<template>
<div class="about">
<h1>This is an about page</h1>
<p class="bg-red-400">Let's try!</p>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import {useCounterStore} from "@/stores/counter";
import TaskInput from "@/components/TaskInput.vue";
import TaskList from '@/components/TaskList.vue'
const counterStore = useCounterStore()
</script>
<template>
<task-input></task-input>
<task-list></task-list>
</template>

20
Tasker.Ui/src/vuetify.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
const vuetify = createVuetify({
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
})
export default vuetify

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./public/**/*.html',
'./src/**/*.{js,jsx,ts,tsx,vue}',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

14
Tasker.Ui/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}

18
Tasker.Ui/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url))
}
})
)

5
Tasker/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

13
Tasker/.idea/.idea.Tasker/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/.idea.Tasker.iml
/projectSettingsUpdater.xml
/modules.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@localhost" uuid="3dea8d67-b2f5-4fc2-915f-7e72ddcf11e8">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

7
Tasker/.idea/.idea.Tasker/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
namespace Tasker.Web.Data;
public class ProjectingDbContext(
DbContextOptions options)
: DbContext(options)
{
public DbSet<Task> Tasks { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Tasker.Web.Data;
public class Task
{
public Guid Id { get; set; }
[MaxLength(255)] public required string Name { get; set; }
[MaxLength(2048)] public string? Description { get; set; }
public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset? CompletedOn { get; set; }
}

View File

@@ -0,0 +1,46 @@
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public record AddTaskRequest(
Guid Id,
string Name,
string? Description,
DateTimeOffset? DueDate);
[PublicAPI]
public class AddTaskEndpoint(
ProjectingDbContext dbContext)
: Endpoint<AddTaskRequest>
{
public override void Configure()
{
Post("/tasks");
Options(o => o.WithTags("Projecting"));
AllowAnonymous();
}
public override async Task HandleAsync(
AddTaskRequest req,
CancellationToken ct)
{
var task = await dbContext.Tasks.AddAsync(
new Data.Task
{
Id = req.Id,
Name = req.Name,
Description = req.Description,
DueDate = req.DueDate
},
cancellationToken: ct);
await dbContext.SaveChangesAsync(ct);
await SendCreatedAtAsync<GetTaskEndpoint>(
task.Entity.Id,
task.Entity,
cancellation: ct);
}
}

View File

@@ -0,0 +1,47 @@
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public record CompleteTaskRequest(
Guid Id,
DateTimeOffset CompleteOn);
[PublicAPI]
public class CompleteTaskEndpoint(
ProjectingDbContext dbContext)
: Endpoint<CompleteTaskRequest>
{
public override void Configure()
{
Post("/tasks/{Id}/complete");
Options(o => o.WithTags("Projecting"));
AllowAnonymous();
}
public override async Task HandleAsync(
CompleteTaskRequest req,
CancellationToken ct)
{
var task = await dbContext
.Tasks
.FindAsync(
[req.Id],
cancellationToken: ct);
if (task is null)
{
await SendNotFoundAsync(ct);
return;
}
task.CompletedOn = req.CompleteOn;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
task,
cancellation: ct);
}
}

View File

@@ -0,0 +1,32 @@
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public record GetTaskRequest(
Guid Id);
[PublicAPI]
public class GetTaskEndpoint(
ProjectingDbContext dbContext)
: Endpoint<GetTaskRequest>
{
public override void Configure()
{
Get("/tasks/{Id}");
Options(o => o.WithTags("Projecting"));
}
public override async Task HandleAsync(
GetTaskRequest req,
CancellationToken ct)
{
var task = await dbContext.FindAsync<GetTaskRequest>(
req.Id);
await SendOkAsync(
task,
cancellation: ct);
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Tasker.Web.Data;
using Task = System.Threading.Tasks.Task;
namespace Tasker.Web.Endpoints;
[PublicAPI]
public class GetTasksEndpoint(
ProjectingDbContext dbContext)
: EndpointWithoutRequest
{
public override void Configure()
{
Get("/tasks");
Options(o => o.WithTags("Projecting"));
AllowAnonymous();
}
public override async Task HandleAsync(
CancellationToken ct)
{
var tasks = await dbContext
.Tasks
.Where(t => t.CompletedOn == null)
.ToListAsync(cancellationToken: ct);
await SendOkAsync(
tasks,
cancellation: ct);
}
}

View File

@@ -0,0 +1,2 @@
global using FastEndpoints;
global using JetBrains.Annotations;

View File

@@ -0,0 +1,53 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data;
#nullable disable
namespace Tasker.Web.Migrations
{
[DbContext(typeof(ProjectingDbContext))]
[Migration("20240811051653_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tasker.Web.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DueDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tasks");
}
}
}

View File

@@ -0,0 +1,56 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data;
#nullable disable
namespace Tasker.Web.Migrations
{
[DbContext(typeof(ProjectingDbContext))]
[Migration("20240812185703_AddCompletedOn")]
partial class AddCompletedOn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tasker.Web.Migrations
{
/// <inheritdoc />
public partial class AddCompletedOn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CompletedOn",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CompletedOn",
table: "Tasks");
}
}
}

View File

@@ -0,0 +1,53 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tasker.Web.Data;
#nullable disable
namespace Tasker.Web.Migrations
{
[DbContext(typeof(ProjectingDbContext))]
partial class ProjectingDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tasker.Web.Data.Task", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedOn")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Tasker.Web.Data;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddFastEndpoints();
builder.Services.AddDbContext<ProjectingDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("ProjectingDbContext")));
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOrigin",
policy =>
{
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseCors("AllowSpecificOrigin");
if (app.Environment.IsDevelopment())
{
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
// specifying the Swagger JSON endpoint
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
c.RoutePrefix = string.Empty; // Set Swagger UI at app's root
});
}
app.UseFastEndpoints();
app.Run();

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5239",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "weatherforecast",
"applicationUrl": "https://localhost:7055;http://localhost:5239",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" Version="5.28.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@Tasker.Web_HostAddress = http://localhost:5239
GET {{Tasker.Web_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"ProjectingDbContext": "Server=127.0.0.1;Port=5432;Database=Tasker;User Id=sa;Password=P@ssword123!;"
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

16
Tasker/Tasker.sln Normal file
View File

@@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tasker.Web", "Tasker.Web\Tasker.Web.csproj", "{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5718F5B9-8EF2-4FE6-ABA4-21BC699F4023}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AActivatorUtilities_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3bfd1dceaf91cf199c7d231f873541c39abbdffae998d84b37f373cf411_003FActivatorUtilities_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkServiceCollectionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FLocal_003FSymbols_003Fsrc_003Fdotnet_003Fefcore_003F0d1256be4658567c8a24b4c027bdbb3dbd6de656_003Fsrc_003FEFCore_003FExtensions_003FEntityFrameworkServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIListSource_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95d8801ed7894a44a3b73e8b963694f8bd910_003Fb0_003Ff108f363_003FIListSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiEndpointRouteBuilderExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5afdf3ad3bf2cb0b47575971c4deac89885ddeb3b2444d56faa3cbd78cd_003FOpenApiEndpointRouteBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F595e4f2361fd31cb66c7e9303f46cf8f2fe77d96eea9d0e42c17cae783aa74_003FServiceCollection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Flowra_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F658ad1fa108317d78bcae889b6e4f9dd5327babb6582972f2dbeabf36dafa5_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>