@ -1,2 +1,8 @@
|
||||
*~
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.exe
|
||||
*.dll
|
||||
|
@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/wwwroot
|
||||
**/bin
|
||||
**/obj
|
||||
**/publish
|
@ -0,0 +1,3 @@
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
nginx.conf
|
@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/wwwroot
|
||||
**/bin
|
||||
**/obj
|
||||
**/publish
|
@ -0,0 +1,18 @@
|
||||
*~
|
||||
.DS_Store
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
.vs/
|
||||
wwwroot/
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
*.exe
|
||||
*.dll
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*proj.user
|
||||
*.sw?
|
||||
coverage
|
@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TodoApi.Model;
|
||||
|
||||
namespace TodoApi.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TodoController : ControllerBase
|
||||
{
|
||||
private const int MaxResultCount = 100;
|
||||
|
||||
private readonly TodoDbContext _db;
|
||||
private readonly ILogger<TodoController> _logger;
|
||||
|
||||
public TodoController(TodoDbContext db, ILogger<TodoController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] int? skip,
|
||||
[FromQuery] int? take,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool? completed)
|
||||
{
|
||||
var q = _db.Todos.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
q = q.Where(e => e.Description.ToLower().Contains(search.ToLower()));
|
||||
}
|
||||
|
||||
if (completed.HasValue)
|
||||
{
|
||||
q = q.Where(e => e.IsCompleted == completed.Value);
|
||||
}
|
||||
|
||||
var totalCount = await q.CountAsync();
|
||||
|
||||
if (skip is { } s)
|
||||
{
|
||||
q = q.Skip(s);
|
||||
}
|
||||
|
||||
if (take is { } t)
|
||||
{
|
||||
q = q.Take(Math.Min(t, MaxResultCount));
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
Items = await q.ToArrayAsync(),
|
||||
totalCount
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] NewTodo input)
|
||||
{
|
||||
var todo = new Todo
|
||||
{
|
||||
Description = input.Description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
IsCompleted = false
|
||||
};
|
||||
await _db.AddAsync(todo);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(todo);
|
||||
}
|
||||
|
||||
[HttpPost("toggle")]
|
||||
public async Task<IActionResult> Toggle(ToggleModel vm)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await _db.Todos.Where(e => vm.Ids.Contains(e.Id))
|
||||
.ExecuteUpdateAsync(setters =>
|
||||
setters
|
||||
.SetProperty(e => e.IsCompleted, e => vm.IsCompleted)
|
||||
.SetProperty(e => e.UpdatedAt, now));
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _db.Todos.Where(e => e.Id == id).ExecuteDeleteAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("completed")]
|
||||
public async Task<IActionResult> DeleteCompleted()
|
||||
{
|
||||
await _db.Todos.Where(e => e.IsCompleted)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status()
|
||||
{
|
||||
var result = await
|
||||
(from todo in _db.Todos
|
||||
group todo by 1 into g
|
||||
select new
|
||||
{
|
||||
totalCount = g.Count(),
|
||||
left = g.Count(e => !e.IsCompleted)
|
||||
}).FirstOrDefaultAsync();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TodoApi.Model
|
||||
{
|
||||
public record NewTodo([Required] string Description);
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TodoApi.Model
|
||||
{
|
||||
public class Todo
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace TodoApi.Model
|
||||
{
|
||||
public class TodoDbContext : DbContext
|
||||
{
|
||||
public TodoDbContext(DbContextOptions options)
|
||||
: base(options)
|
||||
{ }
|
||||
|
||||
public DbSet<Todo> Todos { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("todo_mvc");
|
||||
|
||||
var entityBuilder = modelBuilder.Entity<Todo>();
|
||||
|
||||
entityBuilder.ToTable("todo");
|
||||
|
||||
entityBuilder.Property(e => e.Id)
|
||||
.IsRequired()
|
||||
.HasColumnName("id")
|
||||
.HasColumnType("int")
|
||||
.UseIdentityColumn();
|
||||
|
||||
entityBuilder.Property(e => e.IsCompleted)
|
||||
.IsRequired()
|
||||
.HasColumnName("is_completed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
entityBuilder.Property(e => e.Description)
|
||||
.IsRequired()
|
||||
.HasColumnName("description")
|
||||
.HasColumnType("text");
|
||||
|
||||
entityBuilder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at")
|
||||
.HasColumnType("timestamptz");
|
||||
|
||||
entityBuilder.Property(e => e.UpdatedAt)
|
||||
.IsRequired(false)
|
||||
.HasColumnName("updated_at")
|
||||
.HasColumnType("timestamptz");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TodoApi.Model
|
||||
{
|
||||
public record ToggleModel(
|
||||
[Required] bool IsCompleted,
|
||||
[Required] int[] Ids);
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TodoApi.Model;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var config = builder.Configuration;
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
builder.Services.AddDbContext<TodoDbContext>(o =>
|
||||
{
|
||||
o.UseNpgsql(config.GetConnectionString("PostgreSQL"), opt =>
|
||||
{
|
||||
opt.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o =>
|
||||
{
|
||||
var enumConverter = new JsonStringEnumConverter();
|
||||
o.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.JsonSerializerOptions.AllowTrailingCommas = true;
|
||||
o.JsonSerializerOptions.Converters.Add(enumConverter);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://127.0.0.1:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Trace"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"PostgreSQL": "Host=localhost;Port=5432;Database=todo;Username=todo;Password=todo"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
|
||||
do $$
|
||||
begin
|
||||
create schema if not exists todo_mvc;
|
||||
set search_path to todo_mvc;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
create table if not exists todo (
|
||||
id int not null generated by default as identity,
|
||||
is_completed boolean not null,
|
||||
description text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz null,
|
||||
constraint todo_pkey primary key(id)
|
||||
);
|
||||
end $$;
|
@ -0,0 +1,7 @@
|
||||
**/node_modules
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/wwwroot
|
||||
**/bin
|
||||
**/obj
|
||||
**/publish
|
@ -0,0 +1,14 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
# 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
|
||||
wwwroot/
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*proj.user
|
||||
*.sw?
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
# todo-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) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon96.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Todo List</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "todo-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.5",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"axios": "^1.6.1",
|
||||
"bootstrap": "^5.3.2",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.5.1",
|
||||
"@vitejs/plugin-vue": "^4.4.1",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^4.5.0"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 167 B |
Binary file not shown.
After Width: | Height: | Size: 228 B |
Binary file not shown.
After Width: | Height: | Size: 696 B |
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="wrapper h-100 d-flex flex-column">
|
||||
<div class="content flex-grow-1 flex-shrink-0">
|
||||
<RouterView></RouterView>
|
||||
</div>
|
||||
<footer class="w-100 p-2">
|
||||
Favicon by <a href="https://icons8.com" class="text-secondary">icons8.com</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrapper {
|
||||
>footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,12 @@
|
||||
@import "~bootstrap/scss/bootstrap.scss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f5f5f5;
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="row p-3">
|
||||
<h1 class="col justify-content-center text-center not-found-header text-secondary">No such page!</h1>
|
||||
</div>
|
||||
<div class="row p-3">
|
||||
<h1 class="col justify-content-center text-center not-found-header text-secondary">404</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found-header {
|
||||
font-size: 4rem;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="card mb-0 rounded-0 border-top-0" @mouseover="hover = true" @mouseleave="hover = false">
|
||||
<div class="card-body my-0 py-0">
|
||||
<div class="container p-0 m-0">
|
||||
<div class="row">
|
||||
<div class="col-1 d-flex flex-row justify-content-start align-items-center p-0 ps-lg-2">
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input" :checked="isCompleted" @change="toggle" type="checkbox"
|
||||
role="switch" :disabled="disabled">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-10 d-flex">
|
||||
<div class="vr me-2 h-100"></div>
|
||||
<span class="description py-2" :class="{ 'completed': isCompleted }">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-1 d-flex flex-row align-items-center" v-if="hover">
|
||||
<button class="btn btn-trash p-0 m-0 shadow-none border-0" @click="emit('delete')">
|
||||
<font-awesome-icon icon="fa-solid fa-trash-can fa-5x" class="trash-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
'isCompleted': {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
'description': {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
'disabled': {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'delete'])
|
||||
|
||||
const hover = ref(false)
|
||||
const isCompleted = toRef(() => props.isCompleted)
|
||||
const disabled = toRef(() => props.disabled)
|
||||
|
||||
function toggle() {
|
||||
emit('toggle')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.completed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.btn-trash {
|
||||
color: var(--bs-warning);
|
||||
|
||||
&:hover {
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.trash-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vr {
|
||||
min-height: 2rem;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="row p-3">
|
||||
<h1 class="col justify-content-center text-center todo-header text-secondary">todo</h1>
|
||||
</div>
|
||||
<div class=row>
|
||||
<div class="col-lg-5 offset-lg-4">
|
||||
<div class="card mb-0 rounded-bottom-0">
|
||||
<div class="card-body py-0">
|
||||
<div class="container p-0 m-0">
|
||||
<div class="row">
|
||||
<div class="col-1 d-flex flex-row justify-content-start align-items-center p-0 ps-lg-2">
|
||||
<div class="form-check form-switch m-0" v-if="items.length">
|
||||
<input class="form-check-input" @change="onToggleAll" type="checkbox"
|
||||
:disabled="isLoading || isEmpty" :checked="isCompleted" role="switch" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-11 d-flex">
|
||||
<div class="vr me-2 h-100"></div>
|
||||
<form @submit.prevent="onSubmit" class="w-100 h-100">
|
||||
<input type="text" placeholder="What's next to be done?"
|
||||
class="form-control shadow-none my-2 ps-0 border-0 new-input"
|
||||
v-model="newTodo" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-4">
|
||||
<TodoItem v-for="item in items" :key="item.id" :isCompleted="item.isCompleted"
|
||||
:description="item.description" :disabled="isLoading" @delete="deleteItem(item.id)"
|
||||
@toggle="onToggle(item)">
|
||||
</TodoItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-5 offset-lg-4">
|
||||
<div class="card mb-0 rounded-top-0 border-top-0">
|
||||
<div class="card-body py-0">
|
||||
<div class="container p-0 m-0">
|
||||
<div class="row">
|
||||
<div class="col-3 col-lg-2 py-2">
|
||||
<span v-if="left" class="text-secondary">
|
||||
{{ left }} left
|
||||
</span>
|
||||
</div>
|
||||
<div class="col col-lg-7 d-flex flex-row justify-content-evenly align-items-center py-2">
|
||||
<RouterLink :to="{ name: 'all' }" class="btn btn-outline-secondary px-2 py-0 mx-1">
|
||||
All
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'active' }" class="btn btn-outline-secondary px-2 py-0 mx-1">
|
||||
Active
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'completed' }"
|
||||
class="btn btn-outline-secondary px-2 py-0 mx-1">
|
||||
Completed
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-lg-3 py-2 text-end">
|
||||
<a href="#" v-if="totalCount - left" @click.prevent="deleteCompleted"
|
||||
class="clear-link text-secondary">Clear
|
||||
completed</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-overlay position-absolute" v-if="isLoading">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute, RouterLink } from 'vue-router'
|
||||
import svc from '../services/todoService'
|
||||
import { useDebouncedRef } from '../services/composables'
|
||||
import TodoItem from './TodoItem.vue';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const totalCount = ref(0)
|
||||
const left = ref(0)
|
||||
const search = ref()
|
||||
const items = ref([])
|
||||
const newTodo = ref('')
|
||||
const isCompleted = ref(false)
|
||||
const isLoading = useDebouncedRef(false, 500)
|
||||
const routeName = ref(route.name)
|
||||
|
||||
const completedFilter = computed(() => {
|
||||
const name = routeName.value
|
||||
if (name == 'active') {
|
||||
return false
|
||||
} else if (name == 'completed') {
|
||||
return true
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const isEmpty = computed(() => !totalCount.value)
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const status = await svc.status()
|
||||
totalCount.value = status.totalCount
|
||||
left.value = status.left
|
||||
if (items.value.length > 0) {
|
||||
isCompleted.value = items.value.every(todo => todo.isCompleted)
|
||||
} else {
|
||||
isCompleted.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const rv = await svc.getAll({
|
||||
search: search.value,
|
||||
completed: completedFilter.value
|
||||
})
|
||||
items.value = rv.items
|
||||
totalCount.value = rv.totalCount
|
||||
await updateStatus()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(id) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await svc.delete(id)
|
||||
items.value = items.value.filter(todo => {
|
||||
return todo.id != id
|
||||
})
|
||||
await updateStatus()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const description = newTodo.value
|
||||
if (description) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const todo = await svc.create(description)
|
||||
newTodo.value = null
|
||||
if (routeName.value == 'all' || routeName.value == 'active') {
|
||||
const newItems = [...items.value]
|
||||
newItems.push(todo)
|
||||
items.value = newItems
|
||||
}
|
||||
await updateStatus()
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(todo) {
|
||||
const idx = items.value.findIndex(v => v.id == todo.id)
|
||||
if (idx >= 0) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
todo = { ...todo }
|
||||
todo.isCompleted = !todo.isCompleted
|
||||
await svc.toggle(todo.isCompleted, [todo.id])
|
||||
if (route.name == 'all' ||
|
||||
(route.name == 'active' && !todo.isCompleted) ||
|
||||
(route.name == 'completed' && todo.isCompleted)) {
|
||||
items.value[idx] = todo
|
||||
} else {
|
||||
items.value.splice(idx, 1)
|
||||
}
|
||||
await updateStatus()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const newValue = !isCompleted.value
|
||||
const ids = items.value.map(v => v.id)
|
||||
await svc.toggle(newValue, ids)
|
||||
if ((routeName.value == 'active' && newValue) ||
|
||||
(routeName.value == 'completed' && !newValue)) {
|
||||
items.value.splice(0, items.value.length)
|
||||
} else {
|
||||
items.value = items.value.map(todo => {
|
||||
todo = { ...todo }
|
||||
todo.isCompleted = newValue
|
||||
return todo
|
||||
})
|
||||
}
|
||||
await updateStatus()
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCompleted() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await svc.deleteCompleted();
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
onBeforeRouteUpdate(async to => {
|
||||
routeName.value = to.name
|
||||
await refresh()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.todo-header {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.new-input {
|
||||
&::placeholder {
|
||||
font-style: italic;
|
||||
color: var(--bs-gray-500);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
>div {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-link {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.router-link-active {
|
||||
background-color: var(--bs-secondary);
|
||||
color: var(--bs-btn-active-color)
|
||||
}
|
||||
</style>
|
@ -0,0 +1,21 @@
|
||||
import './assets/main.scss'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import * as bootstrap from 'bootstrap'
|
||||
import { library as iconLibrary } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faTrashCan
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
iconLibrary.add(faTrashCan)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.component("font-awesome-icon", FontAwesomeIcon)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
@ -0,0 +1,36 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import TodoList from '../components/TodoList.vue'
|
||||
import NotFound from '../components/NotFound.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: TodoList,
|
||||
redirect: 'all',
|
||||
children: [
|
||||
{
|
||||
path: 'all',
|
||||
name: 'all',
|
||||
component: TodoList
|
||||
},
|
||||
{
|
||||
path: 'active',
|
||||
name: 'active',
|
||||
component: TodoList
|
||||
},
|
||||
{
|
||||
path: 'completed',
|
||||
name: 'completed',
|
||||
component: TodoList
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '/404', component: NotFound, name: 'NotFound' },
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'NotFound', params: {} } }
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
@ -0,0 +1,38 @@
|
||||
import { ref, customRef } from 'vue'
|
||||
|
||||
const DEFAULT_DEBOUNCE_TIMEOUT = 1000
|
||||
|
||||
function debounce(f, delay = DEFAULT_DEBOUNCE_TIMEOUT, immediate = false) {
|
||||
let timeout
|
||||
return (...args) => {
|
||||
if (immediate && !timeout) {
|
||||
f(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
f(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
function useDebouncedRef(initialValue, delay = DEFAULT_DEBOUNCE_TIMEOUT, immediate = false) {
|
||||
const state = ref(initialValue)
|
||||
const debouncedRef = customRef((track, trigger) => ({
|
||||
get() {
|
||||
track()
|
||||
return state.value
|
||||
},
|
||||
set: debounce(value => {
|
||||
state.value = value
|
||||
trigger()
|
||||
},
|
||||
delay,
|
||||
immediate)
|
||||
}))
|
||||
return debouncedRef
|
||||
}
|
||||
|
||||
export {
|
||||
useDebouncedRef
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const TIMEOUT_MS = 5000
|
||||
|
||||
const BASE_URL = import.meta.env.BASE_URL ?? '/';
|
||||
|
||||
function send(method, url, {params, data} = {}) {
|
||||
return axios({
|
||||
method,
|
||||
url,
|
||||
params,
|
||||
data,
|
||||
timeout: TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
async getAll({search, skip, take, completed} = {}) {
|
||||
let result = await send('GET', `${BASE_URL}api/todo`, {
|
||||
params: {
|
||||
search,
|
||||
skip,
|
||||
take,
|
||||
completed
|
||||
}
|
||||
})
|
||||
return result.data
|
||||
},
|
||||
async status() {
|
||||
let result = await send('GET', `${BASE_URL}api/todo/status`)
|
||||
return result.data
|
||||
},
|
||||
async create(description) {
|
||||
let result = await send('POST', `${BASE_URL}api/todo`, {
|
||||
data: {
|
||||
description
|
||||
}
|
||||
})
|
||||
return result.data
|
||||
},
|
||||
async toggle(isCompleted, ids = []) {
|
||||
let result = await send('POST', `${BASE_URL}api/todo/toggle`, {
|
||||
data: {
|
||||
isCompleted,
|
||||
ids
|
||||
}
|
||||
})
|
||||
return result.data
|
||||
},
|
||||
async delete(id) {
|
||||
let result = await send('DELETE', `${BASE_URL}api/todo/${id}`)
|
||||
return result.data
|
||||
},
|
||||
async deleteCompleted() {
|
||||
let result = await send('DELETE', `${BASE_URL}api/todo/completed`)
|
||||
return result.data
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import * as path from 'node:path'
|
||||
import * as process from 'node:process'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(currentDir, 'src/'),
|
||||
'~bootstrap': path.resolve(currentDir, 'node_modules/bootstrap'),
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(currentDir, 'wwwroot')
|
||||
},
|
||||
base: ((process.env.BASE_PATH ?? '').replace(/\/$/, '') ?? '') + '/',
|
||||
server: {
|
||||
port: 8080,
|
||||
hot: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue