@ -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