Add project files.

This commit is contained in:
roman
2026-06-05 21:39:38 +02:00
parent 63bb199a1f
commit 841037013a
28 changed files with 2342 additions and 0 deletions
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CalendarReminder.Domain\CalendarReminder.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@CalendarReminder.Api_HostAddress = http://localhost:5115
GET {{CalendarReminder.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,58 @@
using CalendarReminder.Api.Data;
using CalendarReminder.Api.Models;
using CalendarReminder.Api.Services;
using CalendarReminder.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace CalendarReminder.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly TokenService _tokenService;
public AuthController(AppDbContext db, TokenService tokenService)
{
_db = db;
_tokenService = tokenService;
}
// POST api/auth/register
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest req)
{
if (await _db.Users.AnyAsync(u => u.Email == req.Email))
return BadRequest("Email je už registrovaný.");
if (await _db.Users.AnyAsync(u => u.Username == req.Username))
return BadRequest("Používateľské meno je obsadené.");
var user = new User
{
Username = req.Username,
Email = req.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password)
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
var token = _tokenService.GenerateToken(user);
return Ok(new AuthResponse(token, user.Username, user.Email));
}
// POST api/auth/login
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest req)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == req.Email);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
return Unauthorized("Nesprávny email alebo heslo.");
var token = _tokenService.GenerateToken(user);
return Ok(new AuthResponse(token, user.Username, user.Email));
}
}
@@ -0,0 +1,203 @@
using CalendarReminder.Api.Data;
using CalendarReminder.Api.Services;
using CalendarReminder.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace CalendarReminder.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class RemindersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly EmailService _email;
public RemindersController(AppDbContext db, EmailService email)
{
_db = db;
_email = email;
}
private int UserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
// GET api/reminders — moje + zdieľané so mnou
[HttpGet]
public async Task<IActionResult> GetAll()
{
var myId = UserId;
var reminders = await _db.Reminders
.Include(r => r.User)
.Include(r => r.Shares).ThenInclude(s => s.SharedWithUser)
.Where(r => r.UserId == myId || r.Shares.Any(s => s.SharedWithUserId == myId))
.OrderBy(r => r.ReminderDate)
.Select(r => new
{
r.Id, r.Title, r.Description, r.ReminderDate,
r.IsCompleted, r.NotificationSent, r.UserId, r.CreatedAt,
Owner = r.User!.Username,
IsOwner = r.UserId == myId,
SharedWith = r.Shares.Select(s => new { s.SharedWithUser!.Id, s.SharedWithUser.Username })
})
.ToListAsync();
return Ok(reminders);
}
// GET api/reminders/5
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var myId = UserId;
var r = await _db.Reminders
.Include(r => r.Shares)
.FirstOrDefaultAsync(r => r.Id == id &&
(r.UserId == myId || r.Shares.Any(s => s.SharedWithUserId == myId)));
return r is null ? NotFound() : Ok(r);
}
// POST api/reminders
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateReminderRequest req)
{
var reminder = new Reminder
{
Title = req.Title,
Description = req.Description,
ReminderDate = req.ReminderDate,
IsCompleted = false,
NotificationSent = false,
UserId = UserId,
CreatedAt = DateTime.UtcNow
};
_db.Reminders.Add(reminder);
await _db.SaveChangesAsync();
// Zdieľaj s vybranými používateľmi
foreach (var uid in req.ShareWithUserIds ?? [])
{
_db.ReminderShares.Add(new ReminderShare { ReminderId = reminder.Id, SharedWithUserId = uid });
}
if (req.ShareWithUserIds?.Count > 0)
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = reminder.Id }, reminder);
}
// PUT api/reminders/5
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateReminderRequest req)
{
var reminder = await _db.Reminders
.Include(r => r.Shares)
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == UserId);
if (reminder is null) return NotFound();
bool dateChanged = reminder.ReminderDate != req.ReminderDate;
reminder.Title = req.Title;
reminder.Description = req.Description;
reminder.ReminderDate = req.ReminderDate;
reminder.IsCompleted = req.IsCompleted;
if (dateChanged) reminder.NotificationSent = false;
// Aktualizuj zdieľanie
_db.ReminderShares.RemoveRange(reminder.Shares);
foreach (var uid in req.ShareWithUserIds ?? [])
_db.ReminderShares.Add(new ReminderShare { ReminderId = id, SharedWithUserId = uid });
await _db.SaveChangesAsync();
return Ok(reminder);
}
// DELETE api/reminders/5
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var reminder = await _db.Reminders
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == UserId);
if (reminder is null) return NotFound();
_db.Reminders.Remove(reminder);
await _db.SaveChangesAsync();
return NoContent();
}
// POST api/reminders/5/send-email
[HttpPost("{id}/send-email")]
public async Task<IActionResult> SendEmail(int id)
{
var myId = UserId;
var reminder = await _db.Reminders
.Include(r => r.User)
.Include(r => r.Shares).ThenInclude(s => s.SharedWithUser)
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == myId);
if (reminder is null) return NotFound();
if (!reminder.Shares.Any()) return BadRequest("Pripomienka nie je zdieľaná so žiadnym používateľom.");
var date = reminder.ReminderDate.ToLocalTime();
var dateStr = date.ToString("dd.MM.yyyy");
var timeStr = date.ToString("HH:mm");
var body = $"""
<div style="font-family:Segoe UI,sans-serif;max-width:520px;margin:auto;background:#161b22;color:#e1e4e8;border-radius:10px;overflow:hidden">
<div style="background:#1f6feb;padding:1.2rem 1.5rem">
<h2 style="margin:0;color:#fff;font-size:1.1rem">📅 Calendar Reminder</h2>
</div>
<div style="padding:1.5rem">
<h3 style="margin:0 0 1rem;color:#58a6ff;font-size:1.2rem">{reminder.Title}</h3>
<table style="width:100%;border-collapse:collapse">
<tr>
<td style="padding:0.4rem 0;color:#8b949e;width:80px">Dátum</td>
<td style="padding:0.4rem 0;font-weight:600">{dateStr}</td>
</tr>
<tr>
<td style="padding:0.4rem 0;color:#8b949e">Čas</td>
<td style="padding:0.4rem 0;font-weight:600">{timeStr}</td>
</tr>
{(reminder.Description != null ? $"""
<tr>
<td style="padding:0.4rem 0;color:#8b949e;vertical-align:top">Popis</td>
<td style="padding:0.4rem 0">{reminder.Description}</td>
</tr>
""" : "")}
<tr>
<td style="padding:0.4rem 0;color:#8b949e">Od</td>
<td style="padding:0.4rem 0">{reminder.User!.Username}</td>
</tr>
</table>
</div>
<div style="padding:0.75rem 1.5rem;border-top:1px solid #30363d;font-size:0.78rem;color:#6e7681">
Calendar Reminder automatická správa
</div>
</div>
""";
var tasks = reminder.Shares
.Where(s => s.SharedWithUser != null)
.Select(s => _email.SendAsync(
s.SharedWithUser!.Email,
s.SharedWithUser.Username,
$"📅 Pripomienka: {reminder.Title} — {dateStr} {timeStr}",
body));
await Task.WhenAll(tasks);
return Ok(new { sent = reminder.Shares.Count });
}
}
public record CreateReminderRequest(
string Title,
string? Description,
DateTime ReminderDate,
List<int>? ShareWithUserIds);
public record UpdateReminderRequest(
string Title,
string? Description,
DateTime ReminderDate,
bool IsCompleted,
List<int>? ShareWithUserIds);
@@ -0,0 +1,34 @@
using CalendarReminder.Api.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace CalendarReminder.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
public UsersController(AppDbContext db) => _db = db;
// GET api/users?search=jan
[HttpGet]
public async Task<IActionResult> Search([FromQuery] string? search)
{
var myId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
var query = _db.Users.Where(u => u.Id != myId);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(u => u.Username.Contains(search) || u.Email.Contains(search));
var users = await query
.Select(u => new { u.Id, u.Username, u.Email })
.Take(20)
.ToListAsync();
return Ok(users);
}
}
+50
View File
@@ -0,0 +1,50 @@
using CalendarReminder.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace CalendarReminder.Api.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Reminder> Reminders => Set<Reminder>();
public DbSet<User> Users => Set<User>();
public DbSet<ReminderShare> ReminderShares => Set<ReminderShare>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Reminder>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.HasOne(e => e.User)
.WithMany(u => u.Reminders)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Email).IsUnique();
entity.HasIndex(e => e.Username).IsUnique();
entity.Property(e => e.Email).IsRequired().HasMaxLength(200);
entity.Property(e => e.Username).IsRequired().HasMaxLength(100);
});
modelBuilder.Entity<ReminderShare>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.ReminderId, e.SharedWithUserId }).IsUnique();
entity.HasOne(e => e.Reminder)
.WithMany(r => r.Shares)
.HasForeignKey(e => e.ReminderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.SharedWithUser)
.WithMany()
.HasForeignKey(e => e.SharedWithUserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,121 @@
// <auto-generated />
using System;
using CalendarReminder.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CalendarReminder.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260604161952_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsCompleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotificationSent")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ReminderDate")
.HasColumnType("datetime(6)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Reminders");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.HasOne("CalendarReminder.Domain.Entities.User", "User")
.WithMany("Reminders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Navigation("Reminders");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,94 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CalendarReminder.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Username = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Email = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PasswordHash = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Reminders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ReminderDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsCompleted = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
NotificationSent = table.Column<bool>(type: "tinyint(1)", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reminders", x => x.Id);
table.ForeignKey(
name: "FK_Reminders_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Reminders_UserId",
table: "Reminders",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Username",
table: "Users",
column: "Username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Reminders");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,169 @@
// <auto-generated />
using System;
using CalendarReminder.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CalendarReminder.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260604182545_AddReminderShares")]
partial class AddReminderShares
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsCompleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotificationSent")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ReminderDate")
.HasColumnType("datetime(6)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Reminders");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("ReminderId")
.HasColumnType("int");
b.Property<int>("SharedWithUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SharedWithUserId");
b.HasIndex("ReminderId", "SharedWithUserId")
.IsUnique();
b.ToTable("ReminderShares");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.HasOne("CalendarReminder.Domain.Entities.User", "User")
.WithMany("Reminders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
{
b.HasOne("CalendarReminder.Domain.Entities.Reminder", "Reminder")
.WithMany("Shares")
.HasForeignKey("ReminderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CalendarReminder.Domain.Entities.User", "SharedWithUser")
.WithMany()
.HasForeignKey("SharedWithUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Reminder");
b.Navigation("SharedWithUser");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.Navigation("Shares");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Navigation("Reminders");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CalendarReminder.Api.Migrations
{
/// <inheritdoc />
public partial class AddReminderShares : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ReminderShares",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ReminderId = table.Column<int>(type: "int", nullable: false),
SharedWithUserId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReminderShares", x => x.Id);
table.ForeignKey(
name: "FK_ReminderShares_Reminders_ReminderId",
column: x => x.ReminderId,
principalTable: "Reminders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ReminderShares_Users_SharedWithUserId",
column: x => x.SharedWithUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ReminderShares_ReminderId_SharedWithUserId",
table: "ReminderShares",
columns: new[] { "ReminderId", "SharedWithUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReminderShares_SharedWithUserId",
table: "ReminderShares",
column: "SharedWithUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReminderShares");
}
}
}
@@ -0,0 +1,166 @@
// <auto-generated />
using System;
using CalendarReminder.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CalendarReminder.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<bool>("IsCompleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotificationSent")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ReminderDate")
.HasColumnType("datetime(6)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Reminders");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("ReminderId")
.HasColumnType("int");
b.Property<int>("SharedWithUserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("SharedWithUserId");
b.HasIndex("ReminderId", "SharedWithUserId")
.IsUnique();
b.ToTable("ReminderShares");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.HasOne("CalendarReminder.Domain.Entities.User", "User")
.WithMany("Reminders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.ReminderShare", b =>
{
b.HasOne("CalendarReminder.Domain.Entities.Reminder", "Reminder")
.WithMany("Shares")
.HasForeignKey("ReminderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CalendarReminder.Domain.Entities.User", "SharedWithUser")
.WithMany()
.HasForeignKey("SharedWithUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Reminder");
b.Navigation("SharedWithUser");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.Reminder", b =>
{
b.Navigation("Shares");
});
modelBuilder.Entity("CalendarReminder.Domain.Entities.User", b =>
{
b.Navigation("Reminders");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,5 @@
namespace CalendarReminder.Api.Models;
public record RegisterRequest(string Username, string Email, string Password);
public record LoginRequest(string Email, string Password);
public record AuthResponse(string Token, string Username, string Email);
+60
View File
@@ -0,0 +1,60 @@
using CalendarReminder.Api.Data;
using CalendarReminder.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Controllers + OpenAPI
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// MariaDB / EF Core
var connStr = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseMySql(connStr, ServerVersion.AutoDetect(connStr)));
// JWT autentifikácia
var jwtSecret = builder.Configuration["Jwt:Secret"]!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true
};
});
builder.Services.AddAuthorization();
// Services
builder.Services.AddScoped<TokenService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddHostedService<ReminderNotificationService>();
var app = builder.Build();
// Auto-migrate pri štarte
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
if (app.Environment.IsDevelopment())
app.MapOpenApi();
app.UseDefaultFiles(); // servuje index.html z wwwroot
app.UseStaticFiles(); // frontend súbory
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5115",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7160;http://localhost:5115",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,51 @@
using MailKit.Net.Smtp;
using MimeKit;
namespace CalendarReminder.Api.Services;
public class EmailSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string From { get; set; } = "";
public bool UseSsl { get; set; } = false;
}
public class EmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration config, ILogger<EmailService> logger)
{
_settings = config.GetSection("Email").Get<EmailSettings>() ?? new EmailSettings();
_logger = logger;
}
public async Task SendAsync(string toEmail, string toName, string subject, string body)
{
try
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress("CalendarReminder", _settings.From));
message.To.Add(new MailboxAddress(toName, toEmail));
message.Subject = subject;
message.Body = new TextPart("html") { Text = body };
using var client = new SmtpClient();
await client.ConnectAsync(_settings.Host, _settings.Port, _settings.UseSsl);
if (!string.IsNullOrEmpty(_settings.Username))
await client.AuthenticateAsync(_settings.Username, _settings.Password);
await client.SendAsync(message);
await client.DisconnectAsync(true);
_logger.LogInformation("Email odoslaný na {Email}", toEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Chyba pri odosielaní emailu na {Email}", toEmail);
}
}
}
@@ -0,0 +1,71 @@
using CalendarReminder.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace CalendarReminder.Api.Services;
public class ReminderNotificationService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ReminderNotificationService> _logger;
public ReminderNotificationService(IServiceScopeFactory scopeFactory,
ILogger<ReminderNotificationService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Notification service spustený");
while (!stoppingToken.IsCancellationRequested)
{
await CheckRemindersAsync();
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task CheckRemindersAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
var now = DateTime.UtcNow;
var windowEnd = now.AddMinutes(15);
// Nájdi remindery, ktoré sú do 15 minút a notifikácia ešte nebola odoslaná
var upcoming = await db.Reminders
.Include(r => r.User)
.Where(r => !r.IsCompleted
&& !r.NotificationSent
&& r.ReminderDate >= now
&& r.ReminderDate <= windowEnd)
.ToListAsync();
foreach (var reminder in upcoming)
{
var minutesLeft = (int)(reminder.ReminderDate - now).TotalMinutes;
var body = $"""
<h2>⏰ Pripomienka: {reminder.Title}</h2>
<p><strong>Čas:</strong> {reminder.ReminderDate:dd.MM.yyyy HH:mm}</p>
<p><strong>O:</strong> {minutesLeft} minút</p>
{(reminder.Description != null ? $"<p><strong>Popis:</strong> {reminder.Description}</p>" : "")}
<hr>
<small>CalendarReminder</small>
""";
await emailService.SendAsync(
reminder.User!.Email,
reminder.User.Username,
$"⏰ Pripomienka: {reminder.Title}",
body);
reminder.NotificationSent = true;
}
if (upcoming.Count > 0)
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,39 @@
using CalendarReminder.Domain.Entities;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace CalendarReminder.Api.Services;
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Email, user.Email)
};
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddDays(7),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=192.168.1.68;Port=3306;Database=calendarreminder;User=mip;Password=mip@2013;"
},
"Jwt": {
"Secret": "Hhb$bHfHsi@*lqxd1noC&DPK!$SL8fy9x",
"Issuer": "CalendarReminder",
"Audience": "CalendarReminderUsers"
},
"Email": {
"Host": "smtp.gmail.com",
"Port": 465,
"Username": "nas@culak.sk",
"Password": "ggTdYzhRfFlK3RoiojOr",
"From": "nas@culak.sk",
"UseSsl": true
}
}
+931
View File
@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Reminder</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; width: 100%;
font-family: 'Segoe UI', sans-serif;
background: #0d1117; color: #e1e4e8;
overflow: hidden;
}
/* ══════════════════════════════
AUTH
══════════════════════════════ */
#auth-screen {
display: flex; align-items: center; justify-content: center;
height: 100%; background: linear-gradient(135deg, #1a1d2e, #0d1117);
}
.auth-card {
background: #161b22; border: 1px solid #30363d; border-radius: 14px;
padding: 2.25rem; width: 100%; max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.auth-logo { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.3rem; }
.auth-logo .logo-icon { font-size: 1.8rem; }
.auth-logo h1 { font-size: 1.45rem; color: #58a6ff; font-weight: 700; letter-spacing: -0.3px; }
.auth-card > p { color: #8b949e; font-size: 0.88rem; margin-bottom: 1.75rem; padding-left: 2.4rem; }
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.tab-btn { flex: 1; padding: 0.5rem; border: 1px solid #30363d; border-radius: 7px; background: transparent; color: #8b949e; cursor: pointer; font-size: 0.9rem; transition: all 0.15s; }
.tab-btn.active { background: #1f6feb; border-color: #1f6feb; color: #fff; font-weight: 600; }
.fg { margin-bottom: 0.9rem; }
.fg label { display: block; font-size: 0.8rem; color: #8b949e; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.4px; }
.fg input { width: 100%; padding: 0.6rem 0.85rem; background: #0d1117; border: 1px solid #30363d; border-radius: 7px; color: #e1e4e8; font-size: 0.95rem; outline: none; transition: border-color 0.15s; }
.fg input:focus { border-color: #58a6ff; }
.btn-primary { width: 100%; padding: 0.72rem; background: #238636; border: none; border-radius: 7px; color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer; margin-top: 0.25rem; transition: background 0.15s; }
.btn-primary:hover { background: #2ea043; }
.err { color: #f85149; font-size: 0.82rem; margin-top: 0.65rem; text-align: center; min-height: 1.1rem; }
/* ══════════════════════════════
APP SHELL — 3-row grid
row1: topbar (48px)
row2: app body (fill)
══════════════════════════════ */
#app-screen {
display: none;
flex-direction: column;
height: 100%;
}
/* ── TOP BAR ── */
.topbar {
height: 48px; min-height: 48px;
background: #161b22;
border-bottom: 1px solid #30363d;
display: flex; align-items: center;
padding: 0 1.5rem;
gap: 1rem;
width: 100%;
flex-shrink: 0;
z-index: 100;
}
.topbar-brand {
display: flex; align-items: center; gap: 0.55rem;
flex: 0 0 auto;
}
.topbar-brand .brand-icon { font-size: 1.2rem; }
.topbar-brand h1 {
font-size: 1rem; font-weight: 700; color: #58a6ff;
letter-spacing: -0.2px; white-space: nowrap;
}
.topbar-spacer { flex: 1; }
.topbar-user {
display: flex; align-items: center; gap: 0.9rem;
flex: 0 0 auto;
}
.topbar-avatar {
width: 28px; height: 28px; border-radius: 50%;
background: linear-gradient(135deg, #1f6feb, #388bfd);
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700; color: #fff; flex-shrink: 0;
}
.topbar-username { font-size: 0.88rem; color: #c9d1d9; font-weight: 500; }
.btn-logout {
padding: 0.28rem 0.85rem; background: transparent;
border: 1px solid #30363d; border-radius: 6px;
color: #8b949e; cursor: pointer; font-size: 0.8rem;
transition: all 0.15s;
}
.btn-logout:hover { border-color: #f85149; color: #f85149; }
/* ── APP BODY ── */
.app-body {
flex: 1;
display: flex;
min-height: 0; /* critical for flex child overflow */
}
/* ══════════════════════════════
CALENDAR PANEL — fills remaining space
══════════════════════════════ */
.cal-panel {
flex: 1 1 0%;
min-width: 0;
border-right: 1px solid #30363d;
display: flex; flex-direction: column;
overflow: hidden;
}
.cal-header {
height: 48px; min-height: 48px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid #30363d;
flex-shrink: 0;
}
.cal-title { font-size: 1rem; font-weight: 700; color: #c9d1d9; min-width: 200px; }
.cal-nav { display: flex; gap: 0.4rem; align-items: center; }
.btn-nav {
padding: 0.28rem 0.75rem; background: transparent; border: 1px solid #30363d;
border-radius: 6px; color: #8b949e; cursor: pointer; font-size: 0.9rem;
transition: all 0.15s; line-height: 1;
}
.btn-nav:hover { border-color: #58a6ff; color: #58a6ff; }
.btn-today {
padding: 0.28rem 0.85rem; background: #1f6feb18; border: 1px solid #1f6feb55;
border-radius: 6px; color: #58a6ff; cursor: pointer; font-size: 0.8rem; font-weight: 600;
transition: all 0.15s;
}
.btn-today:hover { background: #1f6feb33; }
/* Calendar grid */
.cal-grid-wrap {
flex: 1;
min-height: 0;
display: flex; flex-direction: column;
padding: 0.6rem;
overflow: hidden;
}
.cal-weekdays {
display: grid; grid-template-columns: repeat(7, 1fr);
margin-bottom: 0.4rem; flex-shrink: 0;
}
.cal-weekday {
text-align: center; font-size: 0.72rem; color: #6e7681;
font-weight: 700; padding: 0.25rem 0; text-transform: uppercase; letter-spacing: 0.5px;
}
.cal-days {
flex: 1;
display: grid; grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 1fr;
gap: 4px;
min-height: 0;
}
.cal-day {
background: #161b22; border: 1px solid #21262d;
border-radius: 6px; padding: 0.3rem 0.4rem;
cursor: pointer; transition: border-color 0.12s;
overflow: hidden; display: flex; flex-direction: column;
min-height: 0;
}
.cal-day:hover { border-color: #388bfd55; }
.cal-day.other-month { opacity: 0.28; }
.cal-day.today { border-color: #388bfd; background: #0d1f3c; }
.cal-day.selected { border-color: #58a6ff; background: #0d1f3c; }
.cal-day.has-reminder { background: #12192a; }
.day-num {
font-size: 0.72rem; font-weight: 700; color: #6e7681;
text-align: right; flex-shrink: 0; margin-bottom: 2px;
}
.cal-day.today .day-num { color: #58a6ff; }
.cal-day-events { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: 2px; min-height: 0; }
.cal-reminder-dot {
font-size: 0.67rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding: 1px 5px; border-radius: 3px; flex-shrink: 0; cursor: pointer; line-height: 1.5;
}
.dot-mine { background: #0f2a0f; color: #3fb950; border-left: 2px solid #3fb950; }
.dot-shared { background: #0d1f3c; color: #79c0ff; border-left: 2px solid #388bfd; }
.dot-done { background: #161b22; color: #484f58; text-decoration: line-through; }
.dot-overdue { background: #2d1515; color: #f85149; border-left: 2px solid #f85149; }
.dot-more { font-size: 0.63rem; color: #484f58; padding: 0 4px; flex-shrink: 0; }
/* ══════════════════════════════
SIDEBAR — fixed 350px
══════════════════════════════ */
.sidebar {
flex: 0 0 350px; width: 350px; min-width: 350px;
display: flex; flex-direction: column;
overflow: hidden;
}
/* Add form */
.add-section {
border-bottom: 1px solid #30363d;
padding: 0.9rem 1rem;
flex-shrink: 0;
background: #0d1117;
}
.add-section h3 { font-size: 0.8rem; color: #8b949e; margin-bottom: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.add-section input, .add-section textarea {
width: 100%; padding: 0.48rem 0.65rem;
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
color: #e1e4e8; font-size: 0.85rem; outline: none;
font-family: inherit; margin-bottom: 0.45rem;
transition: border-color 0.15s;
}
.add-section input:focus, .add-section textarea:focus { border-color: #58a6ff; }
.add-section textarea { resize: none; height: 44px; }
.date-row { display: flex; gap: 0.45rem; }
.date-row input { margin-bottom: 0; }
.date-row input[type="date"] { flex: 1 1 55%; }
.date-row input[type="time"] { flex: 1 1 45%; }
.add-section input[type="date"]::-webkit-calendar-picker-indicator,
.add-section input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(0.65) brightness(1.4);
cursor: pointer; opacity: 0.7;
}
.add-section input[type="date"]::-webkit-calendar-picker-indicator:hover,
.add-section input[type="time"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
.user-picker { position: relative; margin-bottom: 0.45rem; }
.user-picker-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 0.3rem; }
.user-tag {
display: flex; align-items: center; gap: 3px;
background: #0d1f3c; border: 1px solid #1f6feb55; border-radius: 10px;
padding: 1px 8px; font-size: 0.73rem; color: #79c0ff;
}
.user-tag button { background: none; border: none; color: #79c0ff; cursor: pointer; font-size: 0.8rem; padding: 0; line-height: 1; }
.user-picker-input {
width: 100%; padding: 0.48rem 0.65rem;
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
color: #e1e4e8; font-size: 0.85rem; outline: none; cursor: pointer;
transition: border-color 0.15s;
}
.user-picker-input:focus { border-color: #58a6ff; }
.user-dropdown {
display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 200;
background: #1c1f2e; border: 1px solid #30363d; border-radius: 6px;
max-height: 150px; overflow-y: auto; margin-top: 2px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.user-dropdown.open { display: block; }
.user-option { padding: 0.42rem 0.7rem; font-size: 0.82rem; cursor: pointer; display: flex; align-items: center; justify-content: space-between; }
.user-option:hover { background: #30363d; }
.user-option.selected { color: #3fb950; }
.user-option .uname { font-weight: 600; }
.user-option .uemail { font-size: 0.73rem; color: #6e7681; }
.btn-add-reminder {
width: 100%; padding: 0.48rem; background: #238636; border: none;
border-radius: 6px; color: #fff; font-size: 0.85rem; font-weight: 600; cursor: pointer;
transition: background 0.15s;
}
.btn-add-reminder:hover { background: #2ea043; }
/* List */
.list-section { flex: 1; overflow-y: auto; padding: 0.9rem 1rem; min-height: 0; }
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.55rem; }
.list-header h3 { font-size: 0.8rem; color: #8b949e; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.filters { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 0.6rem; }
.filter-btn {
padding: 0.22rem 0.6rem; border: 1px solid #30363d; border-radius: 10px;
background: transparent; color: #8b949e; cursor: pointer; font-size: 0.73rem;
transition: all 0.12s;
}
.filter-btn.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.filter-btn:hover:not(.active) { border-color: #484f58; color: #c9d1d9; }
.reminder-item {
background: #161b22; border: 1px solid #21262d; border-radius: 7px;
padding: 0.55rem 0.7rem; margin-bottom: 0.4rem; cursor: default;
transition: border-color 0.12s;
}
.reminder-item:hover { border-color: #30363d; }
.reminder-item.overdue { border-left: 3px solid #f85149; }
.reminder-item.soon { border-left: 3px solid #e3b341; }
.reminder-item.completed { opacity: 0.4; }
.reminder-item.shared-with-me { border-left: 3px solid #388bfd; }
.ri-top { display: flex; align-items: flex-start; gap: 0.45rem; }
.check-btn {
width: 17px; height: 17px; border-radius: 50%; border: 2px solid #30363d;
background: transparent; cursor: pointer; flex-shrink: 0; margin-top: 2px;
display: flex; align-items: center; justify-content: center; font-size: 9px; color: #fff;
transition: all 0.12s;
}
.check-btn.done { background: #238636; border-color: #238636; }
.ri-title { font-size: 0.85rem; font-weight: 600; flex: 1; line-height: 1.3; }
.ri-title.done { text-decoration: line-through; color: #484f58; }
.ri-actions { display: flex; gap: 3px; opacity: 0; transition: opacity 0.12s; }
.reminder-item:hover .ri-actions { opacity: 1; }
.btn-icon { padding: 2px 5px; background: transparent; border: 1px solid #30363d; border-radius: 4px; color: #8b949e; cursor: pointer; font-size: 0.72rem; }
.btn-icon:hover { border-color: #f85149; color: #f85149; }
.ri-meta { font-size: 0.73rem; color: #6e7681; margin-top: 0.22rem; display: flex; justify-content: space-between; }
.ri-badge { display: inline-block; padding: 1px 5px; border-radius: 6px; font-size: 0.68rem; font-weight: 700; margin-left: 4px; }
.badge-red { background: #2d1515; color: #f85149; }
.badge-yellow { background: #2d2700; color: #e3b341; }
.empty-state { text-align: center; padding: 2.5rem 1rem; color: #484f58; font-size: 0.85rem; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #484f58; }
/* ══════════════════════════════
EDIT MODAL
══════════════════════════════ */
.modal-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.75); align-items: center; justify-content: center; z-index: 300;
}
.modal-overlay.open { display: flex; }
.modal {
background: #1c1f2e; border: 1px solid #30363d; border-radius: 12px;
padding: 1.5rem; width: 100%; max-width: 440px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.modal-head h2 { font-size: 0.95rem; color: #c9d1d9; margin: 0; }
.btn-send-email {
padding: 0.3rem 0.65rem; background: transparent;
border: 1px solid #30363d; border-radius: 6px;
color: #8b949e; cursor: pointer; font-size: 1rem;
transition: all 0.15s; line-height: 1;
}
.btn-send-email:hover { border-color: #58a6ff; color: #58a6ff; }
.btn-send-email:disabled { opacity: 0.4; cursor: not-allowed; }
.send-status { font-size: 0.78rem; margin-top: 0.4rem; min-height: 1rem; text-align: right; }
.modal .fg { margin-bottom: 0.75rem; }
.modal .fg label { display: block; font-size: 0.78rem; color: #8b949e; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.4px; }
.modal .fg input { width: 100%; padding: 0.5rem 0.7rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 0.9rem; outline: none; }
.modal .fg input:focus { border-color: #58a6ff; }
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.1rem; }
.btn-cancel { padding: 0.45rem 1rem; background: transparent; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; cursor: pointer; }
.btn-save { padding: 0.45rem 1.1rem; background: #1f6feb; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-weight: 600; }
</style>
</head>
<body>
<!-- ══════════ AUTH ══════════ -->
<div id="auth-screen">
<div class="auth-card">
<div class="auth-logo">
<span class="logo-icon">📅</span>
<h1>Calendar Reminder</h1>
</div>
<p>Správa udalostí s emailovými notifikáciami</p>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('login')">Prihlásiť sa</button>
<button class="tab-btn" onclick="showTab('register')">Registrácia</button>
</div>
<div id="login-form">
<div class="fg"><label>Email</label><input type="email" id="l-email" placeholder="vas@email.com" /></div>
<div class="fg"><label>Heslo</label><input type="password" id="l-pass" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()" /></div>
<button class="btn-primary" onclick="doLogin()">Prihlásiť sa</button>
<div class="err" id="l-err"></div>
</div>
<div id="register-form" style="display:none">
<div class="fg"><label>Meno</label><input type="text" id="r-name" placeholder="Ján Novák" /></div>
<div class="fg"><label>Email</label><input type="email" id="r-email" placeholder="vas@email.com" /></div>
<div class="fg"><label>Heslo</label><input type="password" id="r-pass" placeholder="min. 6 znakov" onkeydown="if(event.key==='Enter')doRegister()" /></div>
<button class="btn-primary" onclick="doRegister()">Vytvoriť účet</button>
<div class="err" id="r-err"></div>
</div>
</div>
</div>
<!-- ══════════ APP ══════════ -->
<div id="app-screen">
<!-- TOP BAR -->
<div class="topbar">
<div class="topbar-brand">
<span class="brand-icon">📅</span>
<h1>Calendar Reminder</h1>
</div>
<div class="topbar-spacer"></div>
<div class="topbar-user">
<div class="topbar-avatar" id="hdr-avatar">?</div>
<span class="topbar-username" id="hdr-user"></span>
<button class="btn-logout" onclick="doLogout()">Odhlásiť sa</button>
</div>
</div>
<!-- APP BODY -->
<div class="app-body">
<!-- CALENDAR (flex: 1) -->
<div class="cal-panel">
<div class="cal-header">
<div class="cal-nav">
<button class="btn-nav" onclick="changeMonth(-1)">&#8249;</button>
<button class="btn-nav" onclick="changeMonth(1)">&#8250;</button>
<button class="btn-today" onclick="goToday()">Dnes</button>
</div>
<div class="cal-title" id="cal-title"></div>
<div style="min-width:120px"></div>
</div>
<div class="cal-grid-wrap">
<div class="cal-weekdays">
<div class="cal-weekday">Pon</div>
<div class="cal-weekday">Uto</div>
<div class="cal-weekday">Str</div>
<div class="cal-weekday">Štv</div>
<div class="cal-weekday">Pia</div>
<div class="cal-weekday">Sob</div>
<div class="cal-weekday">Ned</div>
</div>
<div class="cal-days" id="cal-days"></div>
</div>
</div>
<!-- SIDEBAR (fixed 350px) -->
<div class="sidebar">
<div class="add-section">
<h3>Nová pripomienka</h3>
<input type="text" id="n-title" placeholder="Názov *" />
<div class="date-row">
<input type="date" id="n-date-day"
onfocus="document.getElementById('picker-dropdown').classList.remove('open')"
onchange="document.getElementById('picker-dropdown').classList.remove('open')" />
<input type="time" id="n-date-time" value="08:00"
onfocus="document.getElementById('picker-dropdown').classList.remove('open')"
onchange="this.blur()" />
</div>
<textarea id="n-desc" placeholder="Popis (voliteľné)"></textarea>
<div class="user-picker" id="user-picker">
<div class="user-picker-tags" id="picker-tags"></div>
<input class="user-picker-input" id="picker-input"
placeholder="👥 Zdieľať s používateľom..."
autocomplete="off"
onfocus="openPicker()"
oninput="searchUsers(this.value)" />
<div class="user-dropdown" id="picker-dropdown"></div>
</div>
<button class="btn-add-reminder" onclick="createReminder()">Pridať pripomienku</button>
</div>
<div class="list-section">
<div class="list-header">
<h3>Zoznam</h3>
</div>
<div class="filters">
<button class="filter-btn active" onclick="setFilter('all',this)">Všetky</button>
<button class="filter-btn" onclick="setFilter('week',this)">Tento týždeň</button>
<button class="filter-btn" onclick="setFilter('today',this)">Dnes</button>
<button class="filter-btn" onclick="setFilter('upcoming',this)">Nadchádzajúce</button>
<button class="filter-btn" onclick="setFilter('completed',this)">Dokončené</button>
</div>
<div id="reminder-list"></div>
</div>
</div>
</div>
</div>
<!-- EDIT MODAL -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-head">
<h2>✏️ Upraviť pripomienku</h2>
<button class="btn-send-email" id="btn-send-email" onclick="sendReminderEmail()" title="Odoslať email zdieľaným používateľom">✉️</button>
</div>
<input type="hidden" id="e-id" />
<div class="fg"><label>Názov</label><input type="text" id="e-title" /></div>
<div class="fg"><label>Dátum a čas</label><input type="datetime-local" id="e-date" /></div>
<div class="fg"><label>Popis</label><input type="text" id="e-desc" /></div>
<div class="send-status" id="send-status"></div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">Zrušiť</button>
<button class="btn-save" onclick="saveEdit()">Uložiť</button>
</div>
</div>
</div>
<script>
const API = '/api';
const v = id => document.getElementById(id).value;
const auth = () => ({ 'Authorization': `Bearer ${token}` });
const MONTHS_SK = ['Január','Február','Marec','Apríl','Máj','Jún','Júl','August','September','Október','November','December'];
let token = localStorage.getItem('token');
let myId = parseInt(localStorage.getItem('myId') || '0');
let reminders = [];
let currentFilter = 'all';
let calYear, calMonth;
let selectedUsers = [];
let allUsers = [];
let pickerDebounce = null;
const now0 = new Date();
calYear = now0.getFullYear();
calMonth = now0.getMonth();
if (token) showApp();
// ═══ AUTH ═══
function showTab(t) {
document.querySelectorAll('.tab-btn').forEach((b,i) => b.classList.toggle('active', i===(t==='login'?0:1)));
document.getElementById('login-form').style.display = t==='login' ? 'block':'none';
document.getElementById('register-form').style.display = t==='register' ? 'block':'none';
}
async function doLogin() {
const res = await fetch(`${API}/auth/login`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ email: v('l-email'), password: v('l-pass') })
});
if (res.ok) { const d = await res.json(); saveAuth(d); showApp(); }
else document.getElementById('l-err').textContent = await res.text();
}
async function doRegister() {
const res = await fetch(`${API}/auth/register`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username: v('r-name'), email: v('r-email'), password: v('r-pass') })
});
if (res.ok) { const d = await res.json(); saveAuth(d); showApp(); }
else document.getElementById('r-err').textContent = await res.text();
}
function saveAuth(d) {
localStorage.setItem('token', d.token);
localStorage.setItem('user', JSON.stringify(d));
try {
const payload = JSON.parse(atob(d.token.split('.')[1]));
const uid = payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] || payload.sub;
myId = parseInt(uid);
localStorage.setItem('myId', String(myId));
} catch {}
token = d.token;
}
function doLogout() {
localStorage.clear(); token = null; myId = 0;
document.getElementById('auth-screen').style.display = 'flex';
document.getElementById('app-screen').style.display = 'none';
}
function showApp() {
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('app-screen').style.display = 'flex';
const u = JSON.parse(localStorage.getItem('user') || '{}');
const name = u.username || '';
document.getElementById('hdr-user').textContent = name;
document.getElementById('hdr-avatar').textContent = name ? name[0].toUpperCase() : '?';
loadReminders();
}
// ═══ DATA ═══
async function loadReminders() {
try {
const res = await fetch(`${API}/reminders`, { headers: auth() });
if (res.status === 401) { doLogout(); return; }
if (!res.ok) {
console.error('API error:', res.status, await res.text());
renderCalendar();
renderList();
return;
}
reminders = await res.json();
} catch (e) {
console.error('Fetch failed:', e);
reminders = [];
}
renderCalendar();
renderList();
}
// ═══ CALENDAR ═══
function renderCalendar() {
document.getElementById('cal-title').textContent = `${MONTHS_SK[calMonth]} ${calYear}`;
const today = new Date();
const firstDay = new Date(calYear, calMonth, 1);
let startDow = firstDay.getDay();
startDow = startDow === 0 ? 6 : startDow - 1;
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
const daysInPrev = new Date(calYear, calMonth, 0).getDate();
const grid = document.getElementById('cal-days');
grid.innerHTML = '';
const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7;
const rowCount = totalCells / 7;
grid.style.gridTemplateRows = `repeat(${rowCount}, 1fr)`;
for (let i = 0; i < totalCells; i++) {
let dayNum, month = calMonth, year = calYear, otherMonth = false;
if (i < startDow) {
dayNum = daysInPrev - startDow + i + 1;
month = calMonth - 1; if (month < 0) { month = 11; year = calYear - 1; }
otherMonth = true;
} else if (i >= startDow + daysInMonth) {
dayNum = i - startDow - daysInMonth + 1;
month = calMonth + 1; if (month > 11) { month = 0; year = calYear + 1; }
otherMonth = true;
} else {
dayNum = i - startDow + 1;
}
const isToday = dayNum === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(dayNum).padStart(2,'0')}`;
const dayRems = reminders.filter(r => r.reminderDate.startsWith(dateStr));
const cell = document.createElement('div');
cell.className = `cal-day${otherMonth?' other-month':''}${isToday?' today':''}${dayRems.length?' has-reminder':''}`;
cell.dataset.date = dateStr;
const numEl = document.createElement('div');
numEl.className = 'day-num';
numEl.textContent = dayNum;
cell.appendChild(numEl);
const eventsEl = document.createElement('div');
eventsEl.className = 'cal-day-events';
dayRems.forEach(r => {
const rd = new Date(r.reminderDate);
const now2 = new Date();
let cls = 'dot-mine';
if (r.isCompleted) cls = 'dot-done';
else if (!r.isOwner) cls = 'dot-shared';
else if (rd < now2) cls = 'dot-overdue';
const time = rd.toLocaleTimeString('sk-SK', {hour:'2-digit',minute:'2-digit'});
const dot = document.createElement('div');
dot.className = `cal-reminder-dot ${cls}`;
dot.title = `${r.title}${time}`;
dot.textContent = `${time} ${r.title}`;
dot.onclick = e => { e.stopPropagation(); openEdit(r.id); };
eventsEl.appendChild(dot);
});
// placeholder pre "+N ďalší" — naplní sa po meraní
const morePlaceholder = document.createElement('div');
morePlaceholder.className = 'dot-more';
morePlaceholder.style.display = 'none';
eventsEl.appendChild(morePlaceholder);
cell.appendChild(eventsEl);
cell.onclick = () => filterByDay(dateStr, cell);
grid.appendChild(cell);
}
// Po vykreslení zmeraj a orizni každé políčko
requestAnimationFrame(() => trimCalendarCells());
}
function trimCalendarCells() {
document.querySelectorAll('.cal-day').forEach(cell => {
const eventsEl = cell.querySelector('.cal-day-events');
if (!eventsEl) return;
const dots = [...eventsEl.querySelectorAll('.cal-reminder-dot')];
const morePlaceholder = eventsEl.querySelector('.dot-more');
if (!dots.length) return;
// Dostupná výška pre udalosti
const numEl = cell.querySelector('.day-num');
const available = cell.clientHeight - (numEl ? numEl.offsetHeight : 0) - 6;
// Zobraz všetky, zmer koľko sa zmestí
dots.forEach(d => d.style.display = '');
if (morePlaceholder) morePlaceholder.style.display = 'none';
let used = 0;
let lastFit = dots.length - 1;
const GAP = 2;
for (let i = 0; i < dots.length; i++) {
used += dots[i].offsetHeight + GAP;
if (used > available) {
lastFit = i - 1;
break;
}
}
const hidden = dots.length - lastFit - 1;
if (hidden <= 0) return;
// Ak sa zmestí aspoň riadok "+N ďalší", schováme posledný viditeľný a nahradíme ho
// Inak schováme od lastFit
for (let i = lastFit + 1; i < dots.length; i++) {
dots[i].style.display = 'none';
}
if (morePlaceholder) {
morePlaceholder.textContent = `+${hidden} ďalší`;
morePlaceholder.style.display = '';
}
});
}
function filterByDay(dateStr, cell) {
const alreadySelected = cell.classList.contains('selected');
document.querySelectorAll('.cal-day.selected').forEach(c => c.classList.remove('selected'));
if (alreadySelected) {
// Druhé kliknutie na rovnaký deň → reset na všetky
resetToAll();
return;
}
cell.classList.add('selected');
// Filtruj list podľa dňa — vlastné aj zdieľané
renderListData(reminders.filter(r => r.reminderDate.startsWith(dateStr)));
}
function changeMonth(dir) {
calMonth += dir;
if (calMonth > 11) { calMonth = 0; calYear++; }
if (calMonth < 0) { calMonth = 11; calYear--; }
renderCalendar();
}
function goToday() {
const t = new Date();
calYear = t.getFullYear(); calMonth = t.getMonth();
renderCalendar();
}
// ═══ LIST ═══
function setFilter(f, btn) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Zruš výber dňa v kalendári — list teraz zobrazuje podľa filtra, nie dňa
document.querySelectorAll('.cal-day.selected').forEach(c => c.classList.remove('selected'));
renderList();
}
function resetToAll() {
currentFilter = 'all';
document.querySelectorAll('.filter-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
document.querySelectorAll('.cal-day.selected').forEach(c => c.classList.remove('selected'));
renderList();
}
function renderList() {
const now = new Date();
const weekEnd = new Date(now); weekEnd.setDate(now.getDate() + 7);
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart); todayEnd.setDate(todayStart.getDate() + 1);
let filtered = reminders.filter(r => {
const d = new Date(r.reminderDate);
switch (currentFilter) {
case 'week': return d >= todayStart && d <= weekEnd;
case 'today': return d >= todayStart && d < todayEnd;
case 'upcoming': return !r.isCompleted && d >= now;
case 'completed': return r.isCompleted;
default: return true; // 'all' — vlastné aj zdieľané, všetky
}
});
renderListData(filtered);
}
function renderListData(list) {
const el = document.getElementById('reminder-list');
if (!list.length) {
el.innerHTML = '<div class="empty-state">📭 Žiadne pripomienky</div>';
return;
}
const now = new Date();
el.innerHTML = list.map(r => {
const d = new Date(r.reminderDate);
const diffMin = (d - now) / 60000;
let cls = '', badge = '';
if (!r.isCompleted) {
if (!r.isOwner) { cls = 'shared-with-me'; }
else if (diffMin < 0) { cls = 'overdue'; badge = '<span class="ri-badge badge-red">Po termíne</span>'; }
else if (diffMin < 60) { cls = 'soon'; badge = '<span class="ri-badge badge-yellow">Čoskoro</span>'; }
}
const dateStr = d.toLocaleDateString('sk-SK') + ' ' + d.toLocaleTimeString('sk-SK', {hour:'2-digit',minute:'2-digit'});
const shared = (r.sharedWith||[]).map(u=>`@${u.username}`).join(', ');
return `
<div class="reminder-item ${cls} ${r.isCompleted?'completed':''}">
<div class="ri-top">
<button class="check-btn ${r.isCompleted?'done':''}"
onclick="toggleComplete(${r.id},${r.isCompleted})">${r.isCompleted?'✓':''}</button>
<div class="ri-title ${r.isCompleted?'done':''}">${r.title}${badge}</div>
<div class="ri-actions">
${r.isOwner?`<button class="btn-icon" onclick="openEdit(${r.id})">✏️</button>`:''}
${r.isOwner?`<button class="btn-icon" onclick="deleteReminder(${r.id})">🗑</button>`:''}
</div>
</div>
<div class="ri-meta">
<span>🗓 ${dateStr}</span>
<span>${!r.isOwner ? `od @${r.owner}` : shared ? `👥 ${shared}` : ''}</span>
</div>
</div>`;
}).join('');
}
// ═══ CRUD ═══
async function createReminder() {
const title = v('n-title').trim();
const dateDay = v('n-date-day');
const dateTime = v('n-date-time') || '00:00';
const desc = v('n-desc').trim();
if (!title || !dateDay) { alert('Vyplňte názov a dátum'); return; }
const reminderDate = new Date(`${dateDay}T${dateTime}`).toISOString();
await fetch(`${API}/reminders`, {
method:'POST', headers:{...auth(),'Content-Type':'application/json'},
body: JSON.stringify({ title, description: desc||null, reminderDate, shareWithUserIds: selectedUsers.map(u=>u.id) })
});
document.getElementById('n-title').value = '';
document.getElementById('n-date-day').value = '';
document.getElementById('n-date-time').value = '08:00';
document.getElementById('n-desc').value = '';
selectedUsers = []; renderPickerTags();
loadReminders();
}
async function toggleComplete(id, cur) {
const r = reminders.find(x=>x.id===id); if (!r) return;
await fetch(`${API}/reminders/${id}`, {
method:'PUT', headers:{...auth(),'Content-Type':'application/json'},
body: JSON.stringify({ title:r.title, description:r.description, reminderDate:r.reminderDate, isCompleted:!cur, shareWithUserIds:(r.sharedWith||[]).map(u=>u.id) })
});
loadReminders();
}
async function deleteReminder(id) {
if (!confirm('Zmazať túto pripomienku?')) return;
await fetch(`${API}/reminders/${id}`, { method:'DELETE', headers:auth() });
loadReminders();
}
// ═══ EDIT MODAL ═══
function openEdit(id) {
const r = reminders.find(x=>x.id===id); if (!r) return;
document.getElementById('e-id').value = r.id;
document.getElementById('e-title').value = r.title;
document.getElementById('e-desc').value = r.description||'';
const d = new Date(r.reminderDate);
document.getElementById('e-date').value = new Date(d - d.getTimezoneOffset()*60000).toISOString().slice(0,16);
document.getElementById('send-status').textContent = '';
// Zobraz tlačidlo obálky iba ak má zdieľaných používateľov
const hasShared = (r.sharedWith||[]).length > 0;
const btnEmail = document.getElementById('btn-send-email');
btnEmail.style.display = hasShared ? '' : 'none';
btnEmail.disabled = false;
document.getElementById('edit-modal').classList.add('open');
}
async function sendReminderEmail() {
const id = document.getElementById('e-id').value;
const btn = document.getElementById('btn-send-email');
const status = document.getElementById('send-status');
btn.disabled = true;
status.style.color = '#8b949e';
status.textContent = 'Odosielam...';
try {
const res = await fetch(`${API}/reminders/${id}/send-email`, {
method: 'POST', headers: auth()
});
if (res.ok) {
const data = await res.json();
status.style.color = '#3fb950';
status.textContent = `✓ Email odoslaný ${data.sent} používateľ${data.sent === 1 ? 'ovi' : 'om'}`;
} else {
const txt = await res.text();
status.style.color = '#f85149';
status.textContent = `${txt}`;
btn.disabled = false;
}
} catch {
status.style.color = '#f85149';
status.textContent = '✗ Chyba pripojenia';
btn.disabled = false;
}
}
function closeModal() { document.getElementById('edit-modal').classList.remove('open'); }
async function saveEdit() {
const id = document.getElementById('e-id').value;
const r = reminders.find(x=>x.id==id);
await fetch(`${API}/reminders/${id}`, {
method:'PUT', headers:{...auth(),'Content-Type':'application/json'},
body: JSON.stringify({ title:v('e-title'), description:v('e-desc')||null, reminderDate:new Date(v('e-date')).toISOString(), isCompleted:r.isCompleted, shareWithUserIds:(r.sharedWith||[]).map(u=>u.id) })
});
closeModal(); loadReminders();
}
// ═══ USER PICKER ═══
function openPicker() { searchUsers(''); }
async function searchUsers(query) {
clearTimeout(pickerDebounce);
pickerDebounce = setTimeout(async () => {
const res = await fetch(`${API}/users?search=${encodeURIComponent(query)}`, { headers: auth() });
if (!res.ok) return;
allUsers = await res.json();
renderDropdown();
}, 200);
}
function renderDropdown() {
const dd = document.getElementById('picker-dropdown');
if (!allUsers.length) { dd.classList.remove('open'); return; }
dd.innerHTML = allUsers.map(u => {
const sel = selectedUsers.some(s=>s.id===u.id);
return `<div class="user-option ${sel?'selected':''}" onclick="toggleUser(${u.id},'${u.username}')">
<div><span class="uname">${u.username}</span>&nbsp;<span class="uemail">${u.email}</span></div>
${sel ? '<span>✓</span>' : ''}
</div>`;
}).join('');
dd.classList.add('open');
}
function toggleUser(id, username) {
const idx = selectedUsers.findIndex(u=>u.id===id);
if (idx >= 0) selectedUsers.splice(idx, 1); else selectedUsers.push({id, username});
renderPickerTags(); renderDropdown();
}
function renderPickerTags() {
document.getElementById('picker-tags').innerHTML = selectedUsers.map(u =>
`<span class="user-tag">@${u.username}<button onclick="removeUser(${u.id})">×</button></span>`
).join('');
}
function removeUser(id) { selectedUsers = selectedUsers.filter(u=>u.id!==id); renderPickerTags(); }
document.addEventListener('click', e => {
if (!document.getElementById('user-picker').contains(e.target))
document.getElementById('picker-dropdown').classList.remove('open');
});
window.addEventListener('resize', () => {
requestAnimationFrame(() => trimCalendarCells());
});
</script>
</body>
</html>
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
namespace CalendarReminder.Domain;
public class Class1
{
}
@@ -0,0 +1,17 @@
namespace CalendarReminder.Domain.Entities;
public class Reminder
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string? Description { get; set; }
public DateTime ReminderDate { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool NotificationSent { get; set; }
// FK
public int UserId { get; set; }
public User? User { get; set; }
public ICollection<ReminderShare> Shares { get; set; } = [];
}
@@ -0,0 +1,10 @@
namespace CalendarReminder.Domain.Entities;
public class ReminderShare
{
public int Id { get; set; }
public int ReminderId { get; set; }
public Reminder? Reminder { get; set; }
public int SharedWithUserId { get; set; }
public User? SharedWithUser { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace CalendarReminder.Domain.Entities;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = "";
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Reminder> Reminders { get; set; } = [];
}
+48
View File
@@ -0,0 +1,48 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalendarReminder.Api", "CalendarReminder.Api\CalendarReminder.Api.csproj", "{1C920E47-D7B4-4B98-907F-CB18D831BF28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalendarReminder.Domain", "CalendarReminder.Domain\CalendarReminder.Domain.csproj", "{E1235840-D959-45E7-950C-AD7E61CD658C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|x64.Build.0 = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Debug|x86.Build.0 = Debug|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|Any CPU.Build.0 = Release|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|x64.ActiveCfg = Release|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|x64.Build.0 = Release|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|x86.ActiveCfg = Release|Any CPU
{1C920E47-D7B4-4B98-907F-CB18D831BF28}.Release|x86.Build.0 = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|x64.ActiveCfg = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|x64.Build.0 = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|x86.ActiveCfg = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Debug|x86.Build.0 = Debug|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|Any CPU.Build.0 = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|x64.ActiveCfg = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|x64.Build.0 = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|x86.ActiveCfg = Release|Any CPU
{E1235840-D959-45E7-950C-AD7E61CD658C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish CalendarReminder.Api/CalendarReminder.Api.csproj -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "CalendarReminder.Api.dll"]
+28
View File
@@ -0,0 +1,28 @@
services:
app:
build: .
ports:
- "8080:8080"
environment:
- ConnectionStrings__Default=Server=db;Port=3306;Database=calendarreminder;User=appuser;Password=secret;
- ASPNETCORE_ENVIRONMENT=Production
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: rootpass
MARIADB_DATABASE: calendarreminder
MARIADB_USER: appuser
MARIADB_PASSWORD: secret
volumes:
- /mnt/tank/calendarreminder/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped