diff --git a/assets/css/app.css b/assets/css/app.css index 7430148..bd53af0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -18,4 +18,10 @@ background-image: linear-gradient(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6)), url('/images/overlay.png'); background-size: cover; } + + /* Modal scroll prevention with content jump fix */ + body.modal-open { + overflow: hidden; + padding-right: var(--scrollbar-width, 0px); + } } \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 1390ead..32dcec1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,12 +23,13 @@ import { LiveSocket } from "phoenix_live_view"; import { hooks } from "phoenix-colocated/cen"; import topbar from "../vendor/topbar"; import Croppr from "./croppr"; +import { ModalScrollFix } from "./modal_scroll_fix"; const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); const liveSocket = new LiveSocket("/live", Socket, { - hooks: { Croppr, ...hooks }, + hooks: { Croppr, ModalScrollFix, ...hooks }, longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, }); diff --git a/assets/js/modal_scroll_fix.js b/assets/js/modal_scroll_fix.js new file mode 100644 index 0000000..009cf5b --- /dev/null +++ b/assets/js/modal_scroll_fix.js @@ -0,0 +1,21 @@ +// Modal scroll fix to prevent content jumping +export const ModalScrollFix = { + mounted() { + this.updateScrollbarWidth(); + this.resizeHandler = () => this.updateScrollbarWidth(); + window.addEventListener('resize', this.resizeHandler); + }, + + destroyed() { + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + } + }, + + updateScrollbarWidth() { + // Calculate scrollbar width + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + // Set CSS custom property + document.documentElement.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`); + } +}; \ No newline at end of file diff --git a/lib/cen/publications.ex b/lib/cen/publications.ex index a2518fc..acfda24 100644 --- a/lib/cen/publications.ex +++ b/lib/cen/publications.ex @@ -18,6 +18,7 @@ defmodule Cen.Publications do @spec get_vacancy!(id :: integer() | binary()) :: Vacancy.t() def get_vacancy!(id) do Vacancy + |> Vacancy.not_deleted() |> Repo.get!(id) |> Repo.preload(:organization) end @@ -25,7 +26,7 @@ defmodule Cen.Publications do @spec list_vacancies_for(User.t()) :: [Vacancy.t()] def list_vacancies_for(user) do user - |> Repo.preload(vacancies: :organization) + |> Repo.preload(vacancies: {Vacancy.not_deleted(), :organization}) |> Map.get(:vacancies) end @@ -58,14 +59,17 @@ defmodule Cen.Publications do ) end - @spec delete_vacancy(Vacancy.t()) :: :ok + @spec delete_vacancy(Vacancy.t()) :: {:ok, Vacancy.t()} | {:error, Ecto.Changeset.t()} def delete_vacancy(vacancy) do - Repo.delete(vacancy) + vacancy + |> Vacancy.soft_delete_changeset() + |> Repo.update() end @spec get_resume!(id :: integer() | binary()) :: Resume.t() def get_resume!(id) do Resume + |> Resume.not_deleted() |> Repo.get!(id) |> Repo.preload([:user]) end @@ -73,7 +77,7 @@ defmodule Cen.Publications do @spec list_resumes_for(user :: User.t()) :: [Resume.t()] def list_resumes_for(user) do user - |> Repo.preload(resumes: :user) + |> Repo.preload(resumes: {Resume.not_deleted(), :user}) |> Map.get(:resumes) end @@ -97,9 +101,11 @@ defmodule Cen.Publications do |> Repo.update() end - @spec delete_resume(Resume.t()) :: :ok + @spec delete_resume(Resume.t()) :: {:ok, Resume.t()} | {:error, Ecto.Changeset.t()} def delete_resume(resume) do - Repo.delete(resume) + resume + |> Resume.soft_delete_changeset() + |> Repo.update() end @spec search_resumes(map()) :: {:ok, {[Resume.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()} @@ -109,6 +115,7 @@ defmodule Cen.Publications do |> Ecto.Changeset.apply_action(:validate) do {:ok, filters} -> Resume + |> Resume.not_deleted() |> filter(:searchable, :search, filters.query) |> filter(:field_of_art, :eq, filters.field_of_art) |> Filters.filter_employment_types(filters.employment_types) @@ -131,6 +138,7 @@ defmodule Cen.Publications do |> Ecto.Changeset.apply_action(:validate) do {:ok, filters} -> Vacancy + |> Vacancy.not_deleted() |> filter(:searchable, :search, filters.query) |> filter(:field_of_art, :eq, filters.field_of_art) |> filter(:min_years_of_work_experience, :not_gt, filters.min_years_of_work_experience) @@ -164,6 +172,7 @@ defmodule Cen.Publications do @spec list_not_reviewed_resumes() :: [Resume.t()] def list_not_reviewed_resumes do Resume + |> Resume.not_deleted() |> where([resume], is_nil(resume.reviewed_at)) |> order_by([resume], asc: resume.updated_at) |> preload(:user) @@ -187,6 +196,7 @@ defmodule Cen.Publications do @spec list_not_reviewed_vacancies() :: [Vacancy.t()] def list_not_reviewed_vacancies do Vacancy + |> Vacancy.not_deleted() |> where([vacancy], is_nil(vacancy.reviewed_at)) |> order_by([vacancy], asc: vacancy.updated_at) |> preload(:organization) diff --git a/lib/cen/publications/resume.ex b/lib/cen/publications/resume.ex index 0b4b1bb..356f7cb 100644 --- a/lib/cen/publications/resume.ex +++ b/lib/cen/publications/resume.ex @@ -3,6 +3,7 @@ defmodule Cen.Publications.Resume do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Cen.Accounts.User alias Cen.Publications.Enums @@ -20,6 +21,7 @@ defmodule Cen.Publications.Resume do field :work_schedules, {:array, Ecto.Enum}, values: Enums.work_schedules() field :reviewed_at, :utc_datetime + field :deleted_at, :utc_datetime embeds_many :educations, Education, on_replace: :delete embeds_many :jobs, Job, on_replace: :delete @@ -90,4 +92,27 @@ defmodule Cen.Publications.Resume do drop_param: :jobs_drop ) end + + @doc """ + Soft deletes the resume by setting `deleted_at`. + """ + @spec soft_delete_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t() + def soft_delete_changeset(resume) do + now = DateTime.utc_now(:second) + change(resume, deleted_at: now) + end + + @doc """ + Query scope to exclude soft-deleted resumes. + """ + def not_deleted(query \\ __MODULE__) do + from(r in query, where: is_nil(r.deleted_at)) + end + + @doc """ + Query scope to include only soft-deleted resumes. + """ + def deleted_only(query \\ __MODULE__) do + from(r in query, where: not is_nil(r.deleted_at)) + end end diff --git a/lib/cen/publications/vacancy.ex b/lib/cen/publications/vacancy.ex index c9fe465..2f86fe6 100644 --- a/lib/cen/publications/vacancy.ex +++ b/lib/cen/publications/vacancy.ex @@ -3,6 +3,7 @@ defmodule Cen.Publications.Vacancy do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Cen.Accounts.User alias Cen.Employers.Organization @@ -24,6 +25,7 @@ defmodule Cen.Publications.Vacancy do field :proposed_salary, :integer field :reviewed_at, :utc_datetime + field :deleted_at, :utc_datetime belongs_to :user, User belongs_to :organization, Organization @@ -101,4 +103,27 @@ defmodule Cen.Publications.Vacancy do defp validate_proposed_salary(changeset) do changeset end + + @doc """ + Soft deletes the vacancy by setting `deleted_at`. + """ + @spec soft_delete_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t() + def soft_delete_changeset(vacancy) do + now = DateTime.utc_now(:second) + change(vacancy, deleted_at: now) + end + + @doc """ + Query scope to exclude soft-deleted vacancies. + """ + def not_deleted(query \\ __MODULE__) do + from(v in query, where: is_nil(v.deleted_at)) + end + + @doc """ + Query scope to include only soft-deleted vacancies. + """ + def deleted_only(query \\ __MODULE__) do + from(v in query, where: not is_nil(v.deleted_at)) + end end diff --git a/lib/cen_web/components/core_components.ex b/lib/cen_web/components/core_components.ex index 3a05505..b3d872a 100644 --- a/lib/cen_web/components/core_components.ex +++ b/lib/cen_web/components/core_components.ex @@ -890,7 +890,7 @@ defmodule CenWeb.CoreComponents do transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} ) |> show("##{id}-container") - |> JS.add_class("overflow-hidden", to: "body") + |> JS.add_class("modal-open", to: "body") |> JS.focus_first(to: "##{id}-content") end @@ -902,7 +902,7 @@ defmodule CenWeb.CoreComponents do ) |> hide("##{id}-container") |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) - |> JS.remove_class("overflow-hidden", to: "body") + |> JS.remove_class("modal-open", to: "body") |> JS.pop_focus() end diff --git a/lib/cen_web/components/delete_confirmation_component.ex b/lib/cen_web/components/delete_confirmation_component.ex new file mode 100644 index 0000000..70d80a1 --- /dev/null +++ b/lib/cen_web/components/delete_confirmation_component.ex @@ -0,0 +1,62 @@ +defmodule CenWeb.DeleteConfirmationComponent do + @moduledoc """ + Universal delete confirmation modal component. + + ## Usage + + <.delete_confirmation + show={@show_delete_modal} + title="Подтвердите удаление резюме" + message="Это действие нельзя отменить. Резюме будет безвозвратно удалено." + confirm_event="delete_resume" + cancel_event="cancel_delete" + /> + """ + use CenWeb, :html + use Phoenix.Component + + @doc """ + Delete confirmation modal component. + + ## Attributes + + * `show` (required) - Boolean to control modal visibility + * `title` (required) - Modal title text + * `message` (required) - Confirmation message text + * `confirm_event` (required) - Event name for confirm action + * `cancel_event` (required) - Event name for cancel action + * `confirm_text` - Confirm button text (defaults to "Да, удалить") + * `cancel_text` - Cancel button text (defaults to "Отмена") + """ + attr :show, :boolean, required: true + attr :title, :string, required: true + attr :message, :string, required: true + attr :confirm_event, :string, required: true + attr :cancel_event, :string, required: true + attr :confirm_text, :string, default: "Да, удалить" + attr :cancel_text, :string, default: "Отмена" + + def delete_confirmation(assigns) do + ~H""" + <.modal :if={@show} show id="delete_confirmation_modal" on_cancel={JS.push(@cancel_event)}> +
+ {@message} +
+