Version 2.4.0

## ⚠️ Breaking changes

- S3 variables are now named: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET
- Users now have roles. Refer to the `roles` table and assign a role to a user with the `role_id` column in the `users` table.

## Features

- Add Admin Panel to manage users and presentations
- Add user roles: user, admin
- Add `LANGUAGES` setting to configure available languages in the app
- Add hideable presenter attendee count (#183 #155)
- Add Hungarian translation (#161)
- Add Latvian translation (#163)
- Add custom S3 endpoint with `S3_SCHEME`, `S3_HOST`, `S3_PORT` and `S3_PUBLIC_URL`

## Fixes and improvements

- Upgrade JS dependencies
- Upgrade Elixir dependencies, including Phoenix Live View to 1.0.17
- Upgrade to Tailwind 4+
- Refactor view templates to use {} instead of <%= %>
- Fix event name validation to be required
- Docker image is now using Ubuntu instead of Alpine for better dependencies support
- Fix scrollbar not showing in event manager when no presentation file (#164) (@aryel780)
- Fix settings scroll for small screen (#168)
- Fix duplicate key quiz when duplicate (#182)
- Fix email change confirmation (#172)
- Fix italian translation (#179)
- Fix random poll choices (#184)
This commit is contained in:
Alex Lion
2025-12-26 14:46:16 +01:00
parent cf7eb77ce2
commit 5bd4793b6e
174 changed files with 23526 additions and 4920 deletions

View File

@@ -3,7 +3,7 @@ BASE_URL=http://localhost:4000
# SECURE_COOKIE=false
DATABASE_URL=postgres://claper:claper@db:5432/claper
SECRET_KEY_BASE=0LZiQBLw4WvqPlz4cz8RsHJlxNiSqM9B48y4ChyJ5v1oA0L/TPIqRjQNdPZN3iEG # Generate with `mix phx.gen.secret`
SECRET_KEY_BASE=0LZiQBLw4WvqPlz4cz8RsHJlxNiSqM9B48y4ChyJ5v1oA0L/TPIqRjQNdPZN3iEG # Generate with `mix phx.gen.secret`
# ⚠️ Don't use this exact value for SECRET_KEY_BASE or someone would be able to sign a cookie with user_id=1 and log in as the admin!
# Storage configuration
@@ -12,10 +12,19 @@ PRESENTATION_STORAGE=local
PRESENTATION_STORAGE_DIR=/app/uploads
#MAX_FILE_SIZE_MB=15
#AWS_ACCESS_KEY_ID=xxx
#AWS_SECRET_ACCESS_KEY=xxx
#AWS_REGION=eu-west-3
#AWS_PRES_BUCKET=xxx
# The standard AWS environment variables
#S3_ACCESS_KEY_ID=xxx
#S3_SECRET_ACCESS_KEY=xxx
#S3_REGION=eu-west-3
#S3_BUCKET=xxx
# If you're using an alternative S3-compatible service, port optional
#S3_SCHEME=https://
#S3_HOST=www.example.com
#S3_PORT=443
# If the public S3-compatible URL is different from the one used to write data
#S3_PUBLIC_URL=https://www.example.com
# Mail configuration
@@ -39,6 +48,7 @@ MAIL_FROM_NAME=Claper
#ALLOW_UNLINK_EXTERNAL_PROVIDER=false
#LOGOUT_REDIRECT_URL=https://google.com
#GS_JPG_RESOLUTION=300x300
#LANGUAGES=en,fr,es,it,nl,de
# OIDC configuration
@@ -49,4 +59,4 @@ MAIL_FROM_NAME=Claper
# OIDC_SCOPES="openid email profile"
# OIDC_LOGO_URL=""
# OIDC_PROPERTY_MAPPINGS="roles:custom_attributes.roles,organization:custom_attributes.organization"
# OIDC_AUTO_REDIRECT_LOGIN=true
# OIDC_AUTO_REDIRECT_LOGIN=true

View File

@@ -42,8 +42,8 @@ jobs:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: '1.16.2'
otp-version: '26'
elixir-version: '1.18.4'
otp-version: '28'
- name: Restore dependencies cache
uses: actions/cache@v3
with:

7
.gitignore vendored
View File

@@ -30,16 +30,21 @@ claper-*.tar
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# Env files except sample
.env*
!.env.sample
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
priv/static/images/.DS_Store
priv/static/.DS_Store
.env
priv/static/fonts/.DS_Store
test/e2e/node_modules
.DS_Store
priv/static/.well-known/apple-developer-merchantid-domain-association
priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt
.elixir_ls
tailwind.config.js

View File

@@ -1,3 +1,35 @@
### v.2.4.0
### ⚠️ Breaking changes
- S3 variables are now named: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET
- Users now have roles. Refer to the `roles` table and assign a role to a user with the `role_id` column in the `users` table.
### Features
- Add Admin Panel to manage users and presentations
- Add user roles: user, admin
- Add `LANGUAGES` setting to configure available languages in the app
- Add hideable presenter attendee count (#183 #155)
- Add Hungarian translation (#161)
- Add Latvian translation (#163)
- Add custom S3 endpoint with `S3_SCHEME`, `S3_HOST`, `S3_PORT` and `S3_PUBLIC_URL`
### Fixes and improvements
- Upgrade JS dependencies
- Upgrade Elixir dependencies, including Phoenix Live View to 1.0.17
- Upgrade to Tailwind 4+
- Refactor view templates to use {} instead of <%= %>
- Fix event name validation to be required
- Docker image is now using Ubuntu instead of Alpine for better dependencies support
- Fix scrollbar not showing in event manager when no presentation file (#164) (@aryel780)
- Fix settings scroll for small screen (#168)
- Fix duplicate key quiz when duplicate (#182)
- Fix email change confirmation (#172)
- Fix italian translation (#179)
- Fix random poll choices (#184)
### v.2.3.2
### Fixes and improvements

112
CLAUDE.md Normal file
View File

@@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Setup and Dependencies
```bash
# Install dependencies
mix deps.get
mix setup # Runs deps.get + ecto.setup
# Setup database
mix ecto.setup # Creates DB, runs migrations, seeds
mix ecto.reset # Drops DB and runs ecto.setup
# Install frontend dependencies
cd assets && npm install && cd ..
```
### Running the Application
```bash
# Start Phoenix server
mix phx.server
# Or inside IEx
iex -S mix phx.server
```
### Testing
```bash
# Run tests
mix test
# Run specific test file
mix test test/path/to/test_file.exs
# Run test with specific line number
mix test test/path/to/test_file.exs:42
```
### Code Quality
```bash
# Format code
mix format
# Run Credo for code analysis
mix credo
```
### Building Assets
```bash
# For production deployment
mix assets.deploy
```
## High-Level Architecture
Claper is an interactive presentation platform built with Phoenix Framework and Elixir. It enables real-time audience interaction during presentations through polls, forms, messages, and quizzes.
### Core Components
1. **Phoenix LiveView Architecture**
- Real-time updates without JavaScript through WebSocket connections
- LiveView modules in `lib/claper_web/live/` handle interactive UI
- Presence tracking for real-time user counts
2. **Main Domain Contexts** (in `lib/claper/`)
- `Accounts` - User management, authentication, OIDC integration
- `Events` - Core presentation/event management
- `Posts` - Audience messages and reactions
- `Polls` - Interactive polls with real-time voting
- `Forms` - Custom forms for audience feedback
- `Quizzes` - Quiz functionality with LTI support
- `Presentations` - Slide management and state tracking
- `Embeds` - External content embedding
3. **Authentication & Authorization**
- Multiple auth methods: email/password, OIDC
- Role-based access control with admin panel
- LTI 1.3 support for educational platforms
4. **Real-time Features**
- Phoenix PubSub for broadcasting updates
- Phoenix Presence for tracking online users
- LiveView for reactive UI without custom JavaScript
5. **Background Jobs**
- Oban for background job processing
- Email sending
6. **Frontend Stack**
- Tailwind CSS for styling (with DaisyUI components)
- Alpine.js for minimal JavaScript interactions
- esbuild for JavaScript bundling
- Separate admin and user interfaces
7. **Key LiveView Modules**
- `EventLive.Show` - Attendee view
- `EventLive.Presenter` - Presenter control view
- `EventLive.Manage` - Event management interface
- `AdminLive.*` - Admin panel components
8. **Database Structure**
- PostgreSQL with Ecto
- Key models: User, Event, Post, Poll, Quiz, PresentationState
- Soft deletes for users
- UUID-based public identifiers
9. **LTI Integration**
- LTI 1.3 support for quizzes, publish score to LMS
- LTI launch handling in `LtiController`

1
CNAME
View File

@@ -1 +0,0 @@
docs.claper.co

View File

@@ -1,28 +1,22 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
# Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.13.2-erlang-24.2.1-debian-bullseye-20210902-slim
#
ARG BUILDER_IMAGE="hexpm/elixir:1.16.0-erlang-26.2.1-alpine-3.18.4"
ARG RUNNER_IMAGE="alpine:3.18.4"
ARG BUILDER_IMAGE="hexpm/elixir:1.18.4-erlang-28.0.1-ubuntu-noble-20250619"
ARG RUNNER_IMAGE="ubuntu:24.04"
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
# RUN apt-get update -y && apt-get install -y curl build-essential git \
# && apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN apk add --no-cache -U build-base git curl bash ca-certificates nodejs npm openssl ncurses
RUN apt-get update && apt-get install -y \
build-essential \
git \
curl \
bash \
ca-certificates \
nodejs \
npm \
openssl \
libncurses5-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_VERSION 16.20.0
ENV NODE_VERSION 22.17.0
ENV PRESENTATION_STORAGE_DIR /app/uploads
# custom ERL_FLAGS are passed for (public) multi-platform builds
@@ -30,19 +24,6 @@ ENV PRESENTATION_STORAGE_DIR /app/uploads
ARG ERL_FLAGS
ENV ERL_FLAGS=$ERL_FLAGS
# Install nvm with node and npm
# RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash \
# && . $HOME/.nvm/nvm.sh \
# && nvm install $NODE_VERSION \
# && nvm alias default $NODE_VERSION \
# && nvm use default
# ENV NODE_PATH $HOME/.nvm/versions/node/v$NODE_VERSION/lib/node_modules
# ENV PATH $HOME/.nvm/versions/node/v$NODE_VERSION/bin:$PATH
# RUN ln -sf $HOME/.nvm/versions/node/v$NODE_VERSION/bin/npm /usr/bin/npm
# RUN ln -sf $HOME/.nvm/versions/node/v$NODE_VERSION/bin/node /usr/bin/node
# prepare build dir
WORKDIR /app
@@ -66,25 +47,14 @@ RUN mix deps.compile
COPY priv priv
# note: if your project uses a tool like https://purgecss.com/,
# which customizes asset compilation based on what it finds in
# your Elixir templates, you will need to move the asset compilation
# step down so that `lib` is available.
COPY assets assets
# Compile the release
COPY lib lib
RUN mix compile
RUN npm install -g sass
RUN cd assets && npm i && \
sass --no-source-map --style=compressed css/custom.scss ../priv/static/assets/custom.css
RUN mix assets.deploy
# compile assets
RUN mix assets.deploy.nosass
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
@@ -94,20 +64,27 @@ RUN mix release
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
# RUN apt-get update -y && apt-get install -y curl libstdc++6 openssl libncurses5 locales ghostscript default-jre libreoffice-java-common \
# && apt-get install -y libreoffice --no-install-recommends && apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN apk add --no-cache curl libstdc++6 openssl ncurses ghostscript openjdk11-jre
RUN apt-get update -y && apt-get install -y curl libstdc++6 openssl locales ghostscript default-jre libreoffice-java-common \
&& apt-get install -y libreoffice --no-install-recommends && apt-get clean && rm -f /var/lib/apt/lists/*_*
# RUN apk add --no-cache curl libstdc++ openssl ncurses ghostscript openjdk11-jre
# Install LibreOffice & Common Fonts
RUN apk --no-cache add bash libreoffice util-linux libreoffice-common \
font-droid-nonlatin font-droid ttf-dejavu ttf-freefont ttf-liberation && \
rm -rf /var/cache/apk/*
RUN apt-get update && apt-get install -y \
libreoffice \
fonts-dejavu \
fonts-freefont-ttf \
fonts-liberation \
fonts-droid-fallback \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Microsoft Core Fonts
RUN apk --no-cache add msttcorefonts-installer fontconfig && \
update-ms-fonts && \
fc-cache -f && \
rm -rf /var/cache/apk/*
RUN apt-get update && apt-get install -y \
ttf-mscorefonts-installer \
fontconfig \
&& fc-cache -f \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en

View File

@@ -61,19 +61,18 @@ RUN ARCH=$(uname -m) && \
&& rm asdf-v${ASDF_VERSION}-linux-${ASDF_ARCH}.tar.gz \
&& mv asdf /usr/bin/
# Install Erlang and Elixir using asdf
RUN asdf plugin add erlang \
&& asdf plugin add elixir \
&& asdf install erlang 26.2.1 \
&& asdf install elixir 1.16.0 \
&& asdf set -u erlang 26.2.1 \
&& asdf set -u elixir 1.16.0
&& asdf install erlang 28.0.1 \
&& asdf install elixir 1.18.4-otp-28 \
&& asdf set -u erlang 28.0.1 \
&& asdf set -u elixir 1.18.4-otp-28
# Install Node.js using asdf
RUN asdf plugin add nodejs \
&& asdf install nodejs 22.1.0 \
&& asdf set -u nodejs 22.1.0
&& asdf install nodejs 22.17.0 \
&& asdf set -u nodejs 22.17.0
ENV PATH="/root/.asdf/shims:$PATH"

View File

@@ -34,7 +34,7 @@ Claper has a two-sided mission:
- The first one is to help these people presenting an idea or a message by giving them the opportunity to make their presentation unique and to have real-time feedback from their audience.
- The second one is to help each participant to take their place, to be an actor in the presentation, in the meeting and to feel important and useful.
Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch, 🇮🇹 Italian
Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch, 🇮🇹 Italian, 🇭🇺 Hungarian, 🇱🇻 Latvian
### Built With

63
assets/css/admin.css Normal file
View File

@@ -0,0 +1,63 @@
@import "tailwindcss";
@plugin "daisyui";
@utility btn {
@apply rounded-full !font-display text-small-body;
}
@utility input {
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
}
@utility select {
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
}
@utility label {
@apply ml-2;
}
@utility fieldset-legend {
@apply ml-3;
}
@plugin "daisyui/theme" {
name: "claper";
default: true; /* set as default */
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
color-scheme: light; /* color of browser-provided UI */
--color-primary: #140753;
--color-primary-content: #ffffff;
--color-secondary: #f4f4f4;
--color-secondary-content: #140553;
--color-accent: #8611ed;
--color-accent-content: #ffffff;
--color-neutral: #000000;
--color-neutral-content: #ffffff;
--color-info: #79bfe2;
--color-info-content: #0e3649;
--color-success: #3cb957;
--color-success-content: #143e1d;
--color-warning: #ffb62e;
--color-warning-content: #523500;
--color-error: #e7000b;
--color-error-content: #fff;
/* border radius */
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
/* base sizes */
--size-selector: 0.25rem;
--size-field: 0.25rem;
/* border size */
--border: 1px;
/* effects */
--depth: 1;
--noise: 0;
}
@import "./modern.css" layer(theme);

View File

@@ -1,12 +1,26 @@
@import url('air-datepicker/air-datepicker.css');
@import url("animate.css/animate.min.css");
@import 'air-datepicker/air-datepicker.css';
@import 'animate.css/animate.min.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@import './theme.css' layer(theme);
@plugin "daisyui" {
themes: light --default;
include: toggle;
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
* {
font-family: 'Roboto', sans-serif;
font-family: var(--font-family-sans);
}
[x-cloak] { display: none !important; }
@@ -19,19 +33,19 @@
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
color: var(--color-primary-700);
background-color: var(--color-primary-100);
border-color: var(--color-primary-200);
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
color: var(--color-supporting-yellow-700);
background-color: var(--color-supporting-yellow-50);
border-color: var(--color-supporting-yellow-100);
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
color: var(--color-supporting-red-700);
background-color: var(--color-supporting-red-100);
border-color: var(--color-supporting-red-200);
}
.alert p {
margin-bottom: 0;
@@ -40,7 +54,7 @@
display: none;
}
.invalid-feedback {
color: #a94442;
color: var(--color-supporting-red-700);
display: block;
margin-top: 2px;
}
@@ -80,12 +94,13 @@
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
border: 1px solid var(--color-neutral-300);
width: 80%;
box-shadow: var(--shadow-lg);
}
.phx-modal-close {
color: #aaa;
color: var(--color-neutral-400);
float: right;
font-size: 28px;
font-weight: bold;
@@ -93,7 +108,7 @@
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
color: var(--color-neutral-900);
text-decoration: none;
cursor: pointer;
}
@@ -104,13 +119,21 @@
.input:focus~.label,
.input:active~.label,
.input.filled~.label {
@apply text-sm transition-all duration-100 -top-1.5 text-primary-500;
font-size: 0.875rem;
transition-property: all;
transition-duration: 100ms;
top: -0.375rem;
color: var(--color-primary-500);
}
.input:focus~.icon,
.input:active~.icon,
.input.filled~.icon {
@apply transition-all duration-100 left-3 top-6 h-5;
transition-property: all;
transition-duration: 100ms;
left: 0.75rem;
top: 1.5rem;
height: 1.25rem;
}
.date-placeholder-hidden::-webkit-datetime-edit {
@@ -284,8 +307,22 @@
url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 100 300 400 500 700 900;
src: url('/fonts/Montserrat/Montserrat-VariableFont_wght.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: italic;
font-weight: 100 300 400 500 700 900;
src: url('/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf');
}
.bg-gradient-animate {
background: linear-gradient(-45deg, #b80fef, #8611ed, #14bfdb, #14bfdb);
background: linear-gradient(-45deg, var(--color-secondary-500), var(--color-secondary-600), var(--color-primary-400), var(--color-primary-400));
background-size: 400% 400%;
animation: gradient 15s ease infinite;
}
@@ -305,7 +342,7 @@
.arrow_box {
position: relative;
background: #fff;
border: 2px solid #e1e1e1;
border: 2px solid var(--color-neutral-100);
}
.arrow_box.arrow_right:after, .arrow_box.arrow_right:before {
left: 100%;
@@ -419,8 +456,7 @@
left: 0;
top: 50%;
right: 0;
border-top: 3px solid;
@apply border-supporting-red-600;
border-top: var(--color-supporting-red-600) 3px solid;
-webkit-transform:rotate(-20deg);
-moz-transform:rotate(-20deg);
@@ -431,15 +467,16 @@
/* Air datepicker */
.air-datepicker-body--day-name {
@apply text-primary-600;
color: var(--color-primary-600);
}
.air-datepicker-cell.-selected-, .air-datepicker-cell.-selected-.-current- {
@apply bg-primary-500 text-white hover:bg-primary-600;
background-color: var(--color-primary-500);
color: var(--color-white);
}
.air-datepicker-cell.-current- {
@apply text-secondary-500;
color: var(--color-secondary-500);
}
.animate__slow_slow {

152
assets/css/modern.css Normal file
View File

@@ -0,0 +1,152 @@
@theme {
/* Colors */
/* Main colors */
--color-*: initial;
--color-sky-blue: #81EBFE;
--color-teal: #14BFDB;
--color-azure: #29ACED;
--color-mauve: #8611ED;
--color-purple: #B80FEF;
--color-navy: #140553;
/* Light colors */
--color-cold-pink: #F3DEFA;
--color-warm-pink: #FFD2F6;
--color-yeallaw: #FFEAC2;
--color-pistacio: #D4FFF4;
--color-sky-tern: #C8EDFF;
--color-tealy: #81EBFE;
--color-earl-night: #0A7BB4;
--color-dark-night: #184992;
/* Gradient */
--gradient-primary: 123deg, var(--color-sky-blue) -36%, var(--color-teal) -12%, var(--color-azure) 21%, var(--color-mauve) 81%, var(--color-purple) 130%;
--gradient-secondary: 123deg, rgba(129, 235, 254, 0.9) -36%, rgba(20, 191, 219, 0.9) -12%, rgba(41, 172, 237, 0.9) 21%, rgba(134, 17, 237, 0.9) 81%, rgba(184, 15, 239, 0.9) 130%;
/* Grayscale */
--color-white: #FFFFFF;
--color-black: #191919;
--color-gray-20: #CCCCCC;
--color-gray-40: #9F9F9F;
--color-gray-60: #737373;
--color-gray-80: #464646;
--color-gray-100: #191919;
--color-gray-120: #000000;
--color-platinum: #F0F0F0;
--color-platinum-20: #FFFFFF;
--color-platinum-40: #FCFCFC;
--color-platinum-60: #F8F8F8;
--color-platinum-80: #F4F4F4;
--color-platinum-100: #F0F0F0;
--color-platinum-120: #EEEEEE;
/* Typography */
--font-display: "Montserrat", sans-serif;
/* Font Sizes - Desktop */
--text-h1: 80px;
--leading-h1: 120%;
--font-weight-h1: 900;
--text-h2: 40px;
--leading-h2: 120%;
--font-weight-h2: 700;
--text-h3: 32px;
--leading-h3: 120%;
--font-weight-h3: 600;
--text-h4: 24px;
--leading-h4: 120%;
--font-weight-h4: 500;
--text-h5: 18px;
--leading-h5: 120%;
--font-weight-h5: 600;
--text-h6: 16px;
--leading-h6: 120%;
--font-weight-h6: 500;
--text-subheading: 20px;
--leading-subheading: 150%;
--font-weight-subheading: 300;
--text-body-bold: 16px;
--leading-body-bold: 150%;
--font-weight-body-bold: 700;
--text-body: 16px;
--leading-body: 150%;
--font-weight-body: 400;
--text-small-body-bold: 14px;
--leading-small-body-bold: 150%;
--font-weight-small-body-bold: 600;
--text-small-body: 14px;
--leading-small-body: 150%;
--font-weight-small-body: 400;
--text-caption: 14px;
--leading-caption: 150%;
--font-weight-caption: 300;
/* Font Sizes - Mobile */
--text-mobile-h1: 32px;
--leading-mobile-h1: 120%;
--font-weight-mobile-h1: 900;
--text-mobile-h2: 28px;
--leading-mobile-h2: 120%;
--font-weight-mobile-h2: 700;
--text-mobile-h3: 22px;
--leading-mobile-h3: 120%;
--font-weight-mobile-h3: 600;
--text-mobile-h4: 18px;
--leading-mobile-h4: 120%;
--font-weight-mobile-h4: 500;
--text-mobile-h5: 16px;
--leading-mobile-h5: 120%;
--font-weight-mobile-h5: 600;
--text-mobile-h6: 14px;
--leading-mobile-h6: 120%;
--font-weight-mobile-h6: 500;
/* Spacing */
--spacing-0: 0px;
--spacing-4: 4px;
--spacing-8: 8px;
--spacing-12: 12px;
--spacing-16: 16px;
--spacing-20: 20px;
--spacing-24: 24px;
--spacing-28: 28px;
--spacing-32: 32px;
--spacing-36: 36px;
--spacing-40: 40px;
--spacing-44: 44px;
--spacing-48: 48px;
--spacing-52: 52px;
--spacing-56: 56px;
--spacing-60: 60px;
--spacing-64: 64px;
--spacing-68: 68px;
--spacing-72: 72px;
--spacing-76: 76px;
--spacing-80: 80px;
--spacing-128: 128px;
/* Font Weights */
--font-weight-regular: 400;
--font-weight-bold: 700;
/* Shadows */
--shadow-3xl: 0px 902px 253px 0px rgba(65, 69, 124, 0.00), 0px 577px 231px 0px rgba(65, 69, 124, 0.01), 0px 325px 195px 0px rgba(65, 69, 124, 0.05), 0px 144px 144px 0px rgba(65, 69, 124, 0.09), 0px 36px 79px 0px rgba(65, 69, 124, 0.10)
}

87
assets/css/theme.css Normal file
View File

@@ -0,0 +1,87 @@
@theme {
/* Primary Colors (water-blue) */
--color-primary-50: #E3F2FD;
--color-primary-100: #C2E3FA;
--color-primary-200: #84C8F6;
--color-primary-300: #3DA7F0;
--color-primary-400: #1395EC;
--color-primary-500: #1186D5;
--color-primary-600: #0D65A1;
--color-primary-700: #0A5689;
--color-primary-800: #0A4B76;
--color-primary-900: #073250;
/* Secondary Colors (electric-purple) */
--color-secondary-50: #F2E0FF;
--color-secondary-100: #E3BDFF;
--color-secondary-200: #C77AFF;
--color-secondary-300: #A62EFF;
--color-secondary-400: #9200FF;
--color-secondary-500: #A327FF;
--color-secondary-600: #6400AD;
--color-secondary-700: #550094;
--color-secondary-800: #490080;
--color-secondary-900: #320057;
/* Neutral Colors (wedgewood) */
--color-neutral-50: #F0F4F8;
--color-neutral-100: #D9E3ED;
--color-neutral-200: #B9CCDF;
--color-neutral-300: #97B3CE;
--color-neutral-400: #7499BE;
--color-neutral-500: #507DAA;
--color-neutral-600: #3F6388;
--color-neutral-700: #314D68;
--color-neutral-800: #253B50;
--color-neutral-900: #1A2938;
/* Supporting Red Colors (rose-madder) */
--color-supporting-red-50: #FCEDEE;
--color-supporting-red-100: #F9D5D7;
--color-supporting-red-200: #F3ABB0;
--color-supporting-red-300: #ED8188;
--color-supporting-red-400: #E75761;
--color-supporting-red-500: #E12D39;
--color-supporting-red-600: #B4242E;
--color-supporting-red-700: #871B22;
--color-supporting-red-800: #5A1217;
--color-supporting-red-900: #2D090B;
/* Supporting Yellow Colors (school-bus-yellow) */
--color-supporting-yellow-50: #FFFBEB;
--color-supporting-yellow-100: #FEF3C7;
--color-supporting-yellow-200: #FDE68A;
--color-supporting-yellow-300: #FCD34D;
--color-supporting-yellow-400: #FBBF24;
--color-supporting-yellow-500: #F59E0B;
--color-supporting-yellow-600: #D97706;
--color-supporting-yellow-700: #B45309;
--color-supporting-yellow-800: #92400E;
--color-supporting-yellow-900: #78350F;
/* Supporting Green Colors (green-teal) */
--color-supporting-green-50: #ECFDF5;
--color-supporting-green-100: #D1FAE5;
--color-supporting-green-200: #A7F3D0;
--color-supporting-green-300: #6EE7B7;
--color-supporting-green-400: #34D399;
--color-supporting-green-500: #10B981;
--color-supporting-green-600: #059669;
--color-supporting-green-700: #047857;
--color-supporting-green-800: #065F46;
--color-supporting-green-900: #064E3B;
/* Font Family */
--font-family-sans: 'Roboto', sans-serif;
--font-family-serif: 'Merriweather', serif;
--font-display: 'Montserrat', sans-serif;
/* Box Shadows */
--shadow-base: 0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06);
--shadow-md: 0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06);
--shadow-lg: 0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1);
--shadow-xl: 0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1);
--shadow-2xl: 0px 25px 50px 0px rgba(0,0,0,0.25);
--shadow-inner: inset 0px 2px 4px 0px rgba(0,0,0,0.06);
}

207
assets/js/admin-charts.js Normal file
View File

@@ -0,0 +1,207 @@
import {
Chart,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
LineController,
Filler
} from 'chart.js';
import 'chartjs-adapter-moment';
// Register Chart.js components
Chart.register(
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
LineController,
Filler
);
export class AdminCharts {
constructor() {
this.charts = {};
this.defaultOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
family: 'Inter, system-ui, sans-serif'
},
color: 'rgba(255, 255, 255, 0.9)'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.9)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 8,
displayColors: false,
padding: 12,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.7)',
font: {
size: 11
}
}
},
y: {
display: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)',
drawBorder: false
},
ticks: {
color: 'rgba(255, 255, 255, 0.7)',
font: {
size: 11
},
callback: function(value) {
return Number.isInteger(value) ? value : '';
}
}
}
},
elements: {
line: {
tension: 0.4,
borderWidth: 3,
fill: true
},
point: {
radius: 0,
hoverRadius: 6,
hoverBorderWidth: 2,
hoverBorderColor: 'rgba(255, 255, 255, 0.9)'
}
},
animation: {
duration: 800,
easing: 'easeInOutQuart'
}
};
}
createUsersChart(canvasId, data) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chartData = {
labels: data.labels,
datasets: [{
label: 'Users',
data: data.values,
borderColor: 'rgba(102, 126, 234, 1)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
pointBackgroundColor: 'rgba(102, 126, 234, 1)',
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
pointHoverBackgroundColor: 'rgba(102, 126, 234, 1)',
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
}]
};
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
}
this.charts[canvasId] = new Chart(ctx, {
type: 'line',
data: chartData,
options: this.defaultOptions
});
return this.charts[canvasId];
}
createEventsChart(canvasId, data) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const chartData = {
labels: data.labels,
datasets: [{
label: 'Events',
data: data.values,
borderColor: 'rgba(16, 185, 129, 1)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
pointBackgroundColor: 'rgba(16, 185, 129, 1)',
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
pointHoverBackgroundColor: 'rgba(16, 185, 129, 1)',
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
}]
};
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
}
this.charts[canvasId] = new Chart(ctx, {
type: 'line',
data: chartData,
options: this.defaultOptions
});
return this.charts[canvasId];
}
updateChart(canvasId, data) {
const chart = this.charts[canvasId];
if (!chart) return;
chart.data.labels = data.labels;
chart.data.datasets[0].data = data.values;
chart.update('active');
}
destroyChart(canvasId) {
if (this.charts[canvasId]) {
this.charts[canvasId].destroy();
delete this.charts[canvasId];
}
}
destroyAllCharts() {
Object.keys(this.charts).forEach(canvasId => {
this.destroyChart(canvasId);
});
}
}
// Create global instance
window.AdminCharts = new AdminCharts();

View File

@@ -13,40 +13,71 @@ import airdatepickerLocaleDe from "air-datepicker/locale/de";
import airdatepickerLocaleEs from "air-datepicker/locale/es";
import airdatepickerLocaleNl from "air-datepicker/locale/nl";
import airdatepickerLocaleIt from "air-datepicker/locale/it";
import airdatepickerLocaleHu from "air-datepicker/locale/hu";
import "moment/locale/de";
import "moment/locale/fr";
import "moment/locale/es";
import "moment/locale/nl";
import "moment/locale/it";
import "moment/locale/hu";
import "moment/locale/lv";
import QRCodeStyling from "qr-code-styling";
import { Presenter } from "./presenter";
import { Manager } from "./manager";
import Split from "split-grid";
import CustomHooks from "./hooks";
import { TourGuideClient } from "@sjmc11/tourguidejs/src/Tour";
import "./admin-charts.js";
window.moment = moment;
const supportedLocales = ["en", "fr", "de", "es", "nl", "it"];
// Get supported locales from backend configuration or fallback to default list
const supportedLocales = window.claperConfig?.supportedLocales || [
"en",
"fr",
"de",
"es",
"nl",
"it",
"hu",
"lv",
];
const airdatePickrSupportedLocales = window.claperConfig?.supportedLocales || [
"en",
"fr",
"de",
"es",
"nl",
"it",
"hu",
];
var locale =
document.querySelector("html").getAttribute("lang") ||
navigator.language.split("-")[0];
var airdatepickrLocale = locale;
if (!supportedLocales.includes(locale)) {
locale = "en";
}
if (!airdatePickrSupportedLocales.includes(locale)) {
airdatepickrLocale = "en";
}
window.moment.locale("en");
window.moment.locale(locale);
window.Alpine = Alpine;
Alpine.start();
let airdatepickerLocale = {
let airdatePickrLocales = {
en: airdatepickerLocaleEn,
fr: airdatepickerLocaleFr,
de: airdatepickerLocaleDe,
es: airdatepickerLocaleEs,
nl: airdatepickerLocaleNl,
it: airdatepickerLocaleIt,
hu: airdatepickerLocaleHu,
};
let csrfToken = document
.querySelector("meta[name='csrf-token']")
@@ -69,8 +100,8 @@ Hooks.EmbeddedBanner = {
Hooks.TourGuide = {
mounted() {
this.triggerDiv = document.querySelector(this.el.dataset.btnTrigger);
this.btnTrigger = this.triggerDiv.querySelector('.open');
this.closeBtnTrigger = this.triggerDiv.querySelector('.close');
this.btnTrigger = this.triggerDiv.querySelector(".open");
this.closeBtnTrigger = this.triggerDiv.querySelector(".close");
this.tour = new TourGuideClient({
nextLabel: this.el.dataset.nextLabel,
@@ -105,7 +136,7 @@ Hooks.TourGuide = {
destroyed() {
this.btnTrigger.removeEventListener("click", () => {
this.startTour();
});
});
this.closeBtnTrigger.removeEventListener("click", () => {
this.triggerDiv.classList.add("hidden");
this.tour.finishTour(true, this.el.dataset.group);
@@ -200,26 +231,36 @@ Hooks.Scroll = {
Hooks.ScrollIntoDiv = {
mounted() {
let useParent = this.el.dataset.useParent === "true";
this.scrollElement = this.el.dataset.useParent === "true" ? this.el.parentElement : this.el;
this.scrollElement =
this.el.dataset.useParent === "true" ? this.el.parentElement : this.el;
this.checkIfAtBottom();
this.scrollToBottom(true);
this.handleEvent("scroll", () => this.scrollToBottom());
this.scrollElement.addEventListener("scroll", () => this.checkIfAtBottom());
},
checkIfAtBottom() {
this.isAtBottom = this.scrollElement.scrollHeight - this.scrollElement.scrollTop - this.scrollElement.clientHeight <= 30;
this.isAtBottom =
this.scrollElement.scrollHeight -
this.scrollElement.scrollTop -
this.scrollElement.clientHeight <=
30;
},
scrollToBottom(force = false) {
if (force || this.isAtBottom) {
this.scrollElement.scrollTo({ top: this.scrollElement.scrollHeight, behavior: "smooth" });
this.scrollElement.scrollTo({
top: this.scrollElement.scrollHeight,
behavior: "smooth",
});
}
},
updated() {
this.scrollToBottom();
},
destroyed() {
this.scrollElement.removeEventListener("scroll", () => this.checkIfAtBottom());
}
this.scrollElement.removeEventListener("scroll", () =>
this.checkIfAtBottom(),
);
},
};
Hooks.NicknamePicker = {
@@ -243,7 +284,7 @@ Hooks.NicknamePicker = {
clicked(e) {
let nickname = prompt(
this.el.dataset.prompt,
localStorage.getItem("nickname") || ""
localStorage.getItem("nickname") || "",
);
if (nickname) {
@@ -265,6 +306,19 @@ Hooks.EmptyNickname = {
},
};
Hooks.SearchableSelect = {
mounted() {
this.handleEvent("update_hidden_field", (payload) => {
if (payload.id === this.el.id) {
this.el.value = payload.value;
// Trigger a change event to update the form
const event = new Event('input', { bubbles: true });
this.el.dispatchEvent(event);
}
});
}
};
Hooks.PostForm = {
onPress(e, submitBtn, TA) {
if (e.key == "Enter" && !e.shiftKey) {
@@ -353,7 +407,7 @@ Hooks.Pickr = {
const utc = moment(date).utc().format("YYYY-MM-DDTHH:mm:ss");
utcTime.value = utc;
},
locale: airdatepickerLocale[locale],
locale: airdatePickrLocales[airdatepickrLocale],
});
},
updated() {},
@@ -392,7 +446,7 @@ Hooks.OpenPresenter = {
window.open(
this.el.dataset.url,
"newwindow",
"width=" + window.screen.width + ",height=" + window.screen.height
"width=" + window.screen.width + ",height=" + window.screen.height,
);
},
mounted() {
@@ -417,7 +471,12 @@ Hooks.GlobalReacts = {
const container = document.createElement("div");
container.innerHTML = svgContent;
const svgElement = container.firstChild;
svgElement.classList.add("react-animation", "absolute", "transform", "opacity-0");
svgElement.classList.add(
"react-animation",
"absolute",
"transform",
"opacity-0",
);
svgElement.classList.add(...this.el.className.split(" "));
this.el.appendChild(svgElement);
}
@@ -429,15 +488,17 @@ Hooks.GlobalReacts = {
preloadSVGs() {
const svgTypes = ["heart", "hundred", "clap", "raisehand"];
svgTypes.forEach(type => {
svgTypes.forEach((type) => {
fetch(`/images/icons/${type}.svg`)
.then(response => response.text())
.then(svgContent => {
.then((response) => response.text())
.then((svgContent) => {
this.svgCache[type] = svgContent;
})
.catch(error => console.error(`Error loading SVG for ${type}:`, error));
.catch((error) =>
console.error(`Error loading SVG for ${type}:`, error),
);
});
}
},
};
Hooks.JoinEvent = {
mounted() {
@@ -570,35 +631,61 @@ Hooks.Dropdown = {
},
};
let Uploaders = {};
Uploaders.S3 = function (entries, onViewError) {
entries.forEach((entry) => {
let formData = new FormData();
let { url, fields } = entry.meta;
Object.entries(fields).forEach(([key, val]) => formData.append(key, val));
formData.append("file", entry.file);
let xhr = new XMLHttpRequest();
onViewError(() => xhr.abort());
xhr.onload = () =>
xhr.status === 204 ? entry.progress(100) : entry.error();
xhr.onerror = () => entry.error();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
let percent = Math.round((event.loaded / event.total) * 100);
if (percent < 100) {
entry.progress(percent);
}
}
Hooks.AdminChart = {
mounted() {
const chartType = this.el.dataset.chartType;
const canvasId = this.el.querySelector('canvas').id;
const data = JSON.parse(this.el.dataset.chartData);
if (chartType === 'users') {
window.AdminCharts.createUsersChart(canvasId, data);
} else if (chartType === 'events') {
window.AdminCharts.createEventsChart(canvasId, data);
}
this.handleEvent("update_chart", (newData) => {
window.AdminCharts.updateChart(canvasId, newData);
});
xhr.open("POST", url, true);
xhr.send(formData);
});
},
updated() {
const chartType = this.el.dataset.chartType;
const canvasId = this.el.querySelector('canvas').id;
const data = JSON.parse(this.el.dataset.chartData);
if (chartType === 'users') {
window.AdminCharts.createUsersChart(canvasId, data);
} else if (chartType === 'events') {
window.AdminCharts.createEventsChart(canvasId, data);
}
},
destroyed() {
const canvasId = this.el.querySelector('canvas').id;
window.AdminCharts.destroyChart(canvasId);
}
};
Hooks.CSVDownloader = {
mounted() {
this.handleEvent("download_csv", ({ filename, content }) => {
const blob = new Blob([content], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
});
}
};
// Merge our custom hooks with the existing hooks
Object.assign(Hooks, CustomHooks);
let liveSocket = new LiveSocket("/live", Socket, {
uploaders: Uploaders,
params: {
_csrf_token: csrfToken,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -629,7 +716,6 @@ window.addEventListener("phx:page-loading-stop", (info) => {
topbar.hide();
});
const onlineUserTemplate = function (user) {
return `
<div id="online-user">

197
assets/js/hooks.js Normal file
View File

@@ -0,0 +1,197 @@
// LiveView hooks for client-side functionality
const Hooks = {
// Hook for handling CSV downloads from LiveView
CSVDownloader: {
mounted() {
this.handleEvent("download_csv", ({ filename, content }) => {
// Create a Blob with the CSV content
const blob = new Blob([content], { type: "text/csv" });
// Create a temporary URL for the Blob
const url = window.URL.createObjectURL(blob);
// Create a temporary link element
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
// Append the link to the document body
document.body.appendChild(link);
// Trigger the download
link.click();
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
});
}
},
// Hook for User Growth Chart
UserGrowthChart: {
mounted() {
// Import Chart.js dynamically
import("chart.js/auto").then(({ default: Chart }) => {
const ctx = this.el.getContext("2d");
const labels = JSON.parse(this.el.dataset.labels);
const values = JSON.parse(this.el.dataset.values);
this.chart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "New Users",
data: values,
borderColor: "#111827",
backgroundColor: "rgba(17, 24, 39, 0.05)",
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 0,
pointBackgroundColor: "transparent",
pointBorderColor: "transparent"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
titleColor: "#fff",
bodyColor: "#fff",
borderColor: "#111827",
borderWidth: 1,
cornerRadius: 4,
displayColors: false,
padding: 8,
titleFont: {
size: 12
},
bodyFont: {
size: 14,
weight: 'bold'
},
callbacks: {
label: function(context) {
return context.parsed.y + ' users';
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: false
}
}
}
});
});
},
destroyed() {
if (this.chart) {
this.chart.destroy();
}
}
},
// Hook for Event Creation Chart
EventCreationChart: {
mounted() {
// Import Chart.js dynamically
import("chart.js/auto").then(({ default: Chart }) => {
const ctx = this.el.getContext("2d");
const labels = JSON.parse(this.el.dataset.labels);
const values = JSON.parse(this.el.dataset.values);
this.chart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "New Events",
data: values,
borderColor: "#111827",
backgroundColor: "rgba(17, 24, 39, 0.05)",
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 0,
pointBackgroundColor: "transparent",
pointBorderColor: "transparent"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
titleColor: "#fff",
bodyColor: "#fff",
borderColor: "#111827",
borderWidth: 1,
cornerRadius: 4,
displayColors: false,
padding: 8,
titleFont: {
size: 12
},
bodyFont: {
size: 14,
weight: 'bold'
},
callbacks: {
label: function(context) {
return context.parsed.y + ' events';
}
}
}
},
scales: {
x: {
display: false
},
y: {
display: false
}
}
}
});
});
},
destroyed() {
if (this.chart) {
this.chart.destroy();
}
}
}
};
export default Hooks;

2912
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,18 +3,20 @@
"deploy": "NODE_ENV=production tailwindcss --postcss --minify --input=css/app.css --output=../priv/static/assets/app.css"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"alpinejs": "^3.13.8",
"autoprefixer": "^10.4.19",
"esbuild": "^0.20.2",
"daisyui": "^5.0.47",
"esbuild": "^0.25.5",
"postcss": "^8.4.38",
"postcss-import": "^15.1.0",
"tailwindcss": "^3.3.3"
"tailwindcss": "^4.1.11"
},
"dependencies": {
"@sjmc11/tourguidejs": "^0.0.16",
"@tailwindcss/container-queries": "^0.1.1",
"air-datepicker": "^3.5.0",
"animate.css": "^4.1.1",
"chart.js": "^4.4.0",
"chartjs-adapter-moment": "^1.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"phoenix": "file:../deps/phoenix",

View File

@@ -1,7 +1,5 @@
module.exports = {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
}
}

View File

@@ -1,134 +0,0 @@
const { colors: defaultColors } = require("tailwindcss/defaultTheme");
const colors = {
...defaultColors,
...{
"water-blue": {
50: "#E3F2FD",
100: "#C2E3FA",
200: "#84C8F6",
300: "#3DA7F0",
400: "#1395EC",
500: "#1186D5",
600: "#0D65A1",
700: "#0A5689",
800: "#0A4B76",
900: "#073250",
},
"electric-purple": {
50: "#F2E0FF",
100: "#E3BDFF",
200: "#C77AFF",
300: "#A62EFF",
400: "#9200FF",
500: "#A327FF",
600: "#6400AD",
700: "#550094",
800: "#490080",
900: "#320057",
},
wedgewood: {
50: "#F0F4F8",
100: "#D9E3ED",
200: "#B9CCDF",
300: "#97B3CE",
400: "#7499BE",
500: "#507DAA",
600: "#3F6388",
700: "#314D68",
800: "#253B50",
900: "#1A2938",
},
"rose-madder": {
50: "#FCEDEE",
100: "#F9D5D7",
200: "#F3ABB0",
300: "#ED8188",
400: "#E75761",
500: "#E12D39",
600: "#B4242E",
700: "#871B22",
800: "#5A1217",
900: "#2D090B",
},
"school-bus-yellow": {
50: "#FFFBEB",
100: "#FEF3C7",
200: "#FDE68A",
300: "#FCD34D",
400: "#FBBF24",
500: "#F59E0B",
600: "#D97706",
700: "#B45309",
800: "#92400E",
900: "#78350F",
},
"green-teal": {
50: "#ECFDF5",
100: "#D1FAE5",
200: "#A7F3D0",
300: "#6EE7B7",
400: "#34D399",
500: "#10B981",
600: "#059669",
700: "#047857",
800: "#065F46",
900: "#064E3B",
},
},
};
module.exports = {
mode: "jit",
content: ["./js/**/*.js", "../lib/*_web/**/*.*ex"],
safelist: [
"-top-1.5",
"top-1",
"left-3",
"top-6",
"h-5",
"left-2.5",
"top-3",
"h-7",
"bg-secondary-600",
"text-white",
"bg-white",
"text-gray-600",
],
darkMode: "media",
theme: {
extend: {
backgroundSize: {
"size-200": "200% 200%",
},
backgroundPosition: {
"pos-0": "0% 0%",
"pos-100": "100% 100%",
},
colors: {
primary: colors["water-blue"],
secondary: colors["electric-purple"],
neutral: colors["wedgewood"],
"supporting-red": colors["rose-madder"],
"supporting-yellow": colors["school-bus-yellow"],
"supporting-green": colors["green-teal"],
},
},
fontFamily: {
sans: ["Roboto", "sans-serif"],
serif: ["Merriweather", "serif"],
},
boxShadow: {
base: "0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06)",
lg: "0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1)",
md: "0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06)",
xl: "0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1)",
"2xl": "0px 25px 50px 0px rgba(0,0,0,0.25)",
inner: "inset 0px 2px 4px 0px rgba(0,0,0,0.06)",
},
},
variants: {
extend: {},
},
plugins: [require("@tailwindcss/container-queries")],
};

View File

@@ -16,15 +16,15 @@
value: claper.co
- name: DATABASE_URL
value: postgresql://claper:claper@10.0.0.6:6432/claper
- name: AWS_ACCESS_KEY_ID
- name: S3_ACCESS_KEY_ID
value: XXX
- name: AWS_PRES_BUCKET
- name: S3_BUCKET
value: XXX
- name: POOL_SIZE
value: "20"
- name: AWS_REGION
- name: S3_REGION
value: eu-west-3
- name: AWS_SECRET_ACCESS_KEY
- name: S3_SECRET_ACCESS_KEY
value: XXX
- name: PRESENTATION_STORAGE
value: s3

View File

@@ -36,9 +36,26 @@ config :dart_sass,
cd: Path.expand("../assets", __DIR__)
]
config :tailwind,
version: "4.1.10",
default: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/app.css
),
cd: Path.expand("..", __DIR__)
],
admin: [
args: ~w(
--input=assets/css/admin.css
--output=priv/static/assets/admin.css
),
cd: Path.expand("..", __DIR__)
]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.12.18",
version: "0.25.5",
default: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),

View File

@@ -13,19 +13,13 @@ config :claper, ClaperWeb.Endpoint,
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]},
sass: {
DartSass,
:install_and_run,
[:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]
},
npx: [
"tailwindcss",
"--input=css/app.css",
"--output=../priv/static/assets/app.css",
"--postcss",
"--watch",
cd: Path.expand("../assets", __DIR__)
]
}
]
# Watch static and templates for browser reloading.

View File

@@ -86,9 +86,39 @@ smtp_tls = get_var_from_path_or_env(config_dir, "SMTP_TLS", "always")
smtp_auth = get_var_from_path_or_env(config_dir, "SMTP_AUTH", "always")
smtp_port = get_int_from_path_or_env(config_dir, "SMTP_PORT", 25)
aws_access_key_id = get_var_from_path_or_env(config_dir, "AWS_ACCESS_KEY_ID", nil)
aws_secret_access_key = get_var_from_path_or_env(config_dir, "AWS_SECRET_ACCESS_KEY", nil)
aws_region = get_var_from_path_or_env(config_dir, "AWS_REGION", nil)
storage = get_var_from_path_or_env(config_dir, "PRESENTATION_STORAGE", "local")
if storage not in ["local", "s3"], do: raise("Invalid PRESENTATION_STORAGE value #{storage}")
s3_access_key_id = get_var_from_path_or_env(config_dir, "S3_ACCESS_KEY_ID")
s3_secret_access_key = get_var_from_path_or_env(config_dir, "S3_SECRET_ACCESS_KEY")
s3_region = get_var_from_path_or_env(config_dir, "S3_REGION")
s3_bucket = get_var_from_path_or_env(config_dir, "S3_BUCKET")
if storage == "s3" and
not Enum.all?([s3_access_key_id, s3_secret_access_key, s3_region, s3_bucket]) do
raise(
"S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET required when PRESENTATION_STORAGE=s3"
)
end
s3_scheme = get_var_from_path_or_env(config_dir, "S3_SCHEME")
s3_host = get_var_from_path_or_env(config_dir, "S3_HOST")
s3_port = get_var_from_path_or_env(config_dir, "S3_PORT")
if storage == "s3" do
if !!s3_scheme and !s3_host, do: "S3_HOST required if S3_SCHEME is set"
if !s3_scheme and !!s3_host, do: "S3_SCHEME required if S3_HOST is set"
end
s3_public_url =
get_var_from_path_or_env(
config_dir,
"S3_PUBLIC_URL",
if(s3_scheme && s3_host,
do: s3_scheme <> s3_host <> if(s3_port, do: ":#{s3_port}", else: ""),
else: "https://#{s3_bucket}.s3.#{s3_region}.amazonaws.com"
)
)
same_site_cookie = get_var_from_path_or_env(config_dir, "SAME_SITE_COOKIE", "Lax")
@@ -128,6 +158,11 @@ allow_unlink_external_provider =
logout_redirect_url = get_var_from_path_or_env(config_dir, "LOGOUT_REDIRECT_URL", nil)
languages =
get_var_from_path_or_env(config_dir, "LANGUAGES", "en,fr,es,it,de")
|> String.split(",")
|> Enum.map(&String.trim/1)
config :claper, :oidc,
enabled: oidc_enabled,
issuer: oidc_issuer,
@@ -166,13 +201,15 @@ config :claper,
enable_account_creation: enable_account_creation,
email_confirmation: email_confirmation,
allow_unlink_external_provider: allow_unlink_external_provider,
logout_redirect_url: logout_redirect_url
logout_redirect_url: logout_redirect_url,
languages: languages
config :claper, :presentations,
max_file_size: max_file_size,
storage: get_var_from_path_or_env(config_dir, "PRESENTATION_STORAGE", "local"),
aws_bucket: get_var_from_path_or_env(config_dir, "AWS_PRES_BUCKET", nil),
resolution: get_var_from_path_or_env(config_dir, "GS_JPG_RESOLUTION", "300x300")
storage: storage,
s3_bucket: s3_bucket,
resolution: get_var_from_path_or_env(config_dir, "GS_JPG_RESOLUTION", "300x300"),
s3_public_url: s3_public_url
config :claper, :mail,
from: get_var_from_path_or_env(config_dir, "MAIL_FROM", "noreply@claper.co"),
@@ -221,9 +258,10 @@ case mail_transport do
end
config :ex_aws,
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key,
region: aws_region,
normalize_path: false
access_key_id: s3_access_key_id,
secret_access_key: s3_secret_access_key,
region: s3_region,
normalize_path: false,
s3: [scheme: s3_scheme, host: s3_host, port: s3_port]
config :swoosh, :api_client, Swoosh.ApiClient.Finch

14
dev.sh
View File

@@ -1,14 +0,0 @@
set -a
source .env
set +a
args=("$@")
if [ "${args[0]}" == "start" ]; then
mix phx.server
elif [ "${args[0]}" == "iex" ]; then
iex -S mix
else
mix "$@"
fi

View File

@@ -1,8 +1,6 @@
services:
db:
image: postgres:15
ports:
- 5432:5432
volumes:
- "claper-db:/var/lib/postgresql/data"
healthcheck:

View File

@@ -1,2 +0,0 @@
elixir_version=1.13.2
erlang_version=24.0

View File

@@ -7,7 +7,7 @@ defmodule Claper.Accounts do
alias Claper.Accounts
alias Claper.Repo
alias Claper.Accounts.{User, UserToken, UserNotifier}
alias Claper.Accounts.{User, UserToken, UserNotifier, Role}
@doc """
Creates a user.
@@ -20,6 +20,9 @@ defmodule Claper.Accounts do
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs) do
# Get user role if not explicitly set
attrs = maybe_set_default_role(attrs)
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert(returning: [:uuid])
@@ -55,18 +58,39 @@ defmodule Claper.Accounts do
def get_user_by_email_or_create(email) when is_binary(email) do
case get_user_by_email(email) do
nil ->
create_user(%{
attrs = %{
email: email,
confirmed_at: DateTime.utc_now(),
is_randomized_password: true,
password: :crypto.strong_rand_bytes(32)
})
}
# Set default role if not explicitly set
attrs = maybe_set_default_role(attrs)
create_user(attrs)
user ->
{:ok, user}
end
end
@doc """
Lists all users that are not deleted.
## Examples
iex> list_users()
[%User{}, ...]
"""
def list_users(preload \\ []) do
User
|> where([u], is_nil(u.deleted_at))
|> Repo.all()
|> Repo.preload(preload)
end
@doc """
Gets a user by email and password.
@@ -147,6 +171,9 @@ defmodule Claper.Accounts do
"""
def register_user(attrs) do
# Get user role if not explicitly set
attrs = maybe_set_default_role(attrs)
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert(returning: [:uuid])
@@ -165,6 +192,37 @@ defmodule Claper.Accounts do
User.registration_changeset(user, attrs, hash_password: false)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes for admin operations.
## Examples
iex> change_user(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user(%User{} = user, attrs \\ %{}) do
User.admin_changeset(user, attrs, hash_password: false)
end
@doc """
Updates a user for admin operations.
## Examples
iex> update_user(user, %{field: new_value})
{:ok, %User{}}
iex> update_user(user, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_user(%User{} = user, attrs) do
user
|> User.admin_changeset(attrs)
|> Repo.update()
end
## Settings
@doc """
@@ -262,6 +320,23 @@ defmodule Claper.Accounts do
end
end
# Alternative version with different signature - keeping for compatibility
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(curr_pw)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
@doc """
Sets the user password.
## Examples
@@ -343,34 +418,6 @@ defmodule Claper.Accounts do
User.password_changeset(user, attrs)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(curr_pw)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Session
@doc """
@@ -602,4 +649,271 @@ defmodule Claper.Accounts do
{:error, changeset} -> {:error, changeset}
end
end
## Role Management
@doc """
Returns the list of roles.
## Examples
iex> list_roles()
[%Role{}, ...]
"""
def list_roles do
Repo.all(Role)
end
@doc """
Gets a single role.
Raises `Ecto.NoResultsError` if the Role does not exist.
## Examples
iex> get_role!(123)
%Role{}
iex> get_role!(456)
** (Ecto.NoResultsError)
"""
def get_role!(id), do: Repo.get!(Role, id)
@doc """
Gets a role by name.
Returns nil if the Role does not exist.
## Examples
iex> get_role_by_name("admin")
%Role{}
iex> get_role_by_name("nonexistent")
nil
"""
def get_role_by_name(name) when is_binary(name) do
Repo.get_by(Role, name: name)
end
@doc """
Creates a role.
## Examples
iex> create_role(%{field: value})
{:ok, %Role{}}
iex> create_role(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_role(attrs \\ %{}) do
%Role{}
|> Role.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a role.
## Examples
iex> update_role(role, %{field: new_value})
{:ok, %Role{}}
iex> update_role(role, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_role(%Role{} = role, attrs) do
role
|> Role.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a role.
## Examples
iex> delete_role(role)
{:ok, %Role{}}
iex> delete_role(role)
{:error, %Ecto.Changeset{}}
"""
def delete_role(%Role{} = role) do
Repo.delete(role)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking role changes.
## Examples
iex> change_role(role)
%Ecto.Changeset{data: %Role{}}
"""
def change_role(%Role{} = role, attrs \\ %{}) do
Role.changeset(role, attrs)
end
@doc """
Assigns a role to a user by role name or role struct.
## Examples
iex> assign_role(user, "admin")
{:ok, %User{}}
iex> assign_role(user, %Role{id: 1})
{:ok, %User{}}
iex> assign_role(user, "unknown")
{:error, :role_not_found}
"""
def assign_role(%User{} = user, %Role{} = role) do
user
|> Ecto.Changeset.change(%{role_id: role.id})
|> Repo.update()
end
def assign_role(%User{} = user, role_name) when is_binary(role_name) do
case get_role_by_name(role_name) do
nil ->
{:error, :role_not_found}
role ->
user
|> Ecto.Changeset.change(%{role_id: role.id})
|> Repo.update()
end
end
@doc """
Gets the role of a user.
## Examples
iex> get_user_role(user)
%Role{}
iex> get_user_role(user_without_role)
nil
"""
def get_user_role(%User{} = user) do
user = user |> Repo.preload(:role)
user.role
end
@doc """
Lists users by role name.
## Examples
iex> list_users_by_role("admin")
[%User{}, ...]
iex> list_users_by_role("unknown")
[]
"""
def list_users_by_role(role_name) when is_binary(role_name) do
case get_role_by_name(role_name) do
nil ->
[]
role ->
User
|> where([u], u.role_id == ^role.id and is_nil(u.deleted_at))
|> Repo.all()
end
end
@doc """
Checks if a user has a specific role.
## Examples
iex> user_has_role?(user, "admin")
true
iex> user_has_role?(user, "unknown")
false
"""
def user_has_role?(%User{} = user, role_name) when is_binary(role_name) do
case get_user_role(user) do
nil -> false
role -> role.name == role_name
end
end
@doc """
Promotes a user to admin role.
## Examples
iex> promote_to_admin(user)
{:ok, %User{}}
iex> promote_to_admin(already_admin_user)
{:ok, %User{}}
"""
def promote_to_admin(%User{} = user) do
assign_role(user, "admin")
end
@doc """
Demotes a user from admin role to regular user role.
## Examples
iex> demote_from_admin(admin_user)
{:ok, %User{}}
iex> demote_from_admin(already_user_user)
{:ok, %User{}}
"""
def demote_from_admin(%User{} = user) do
assign_role(user, "user")
end
# Private helper to set default role if not already set
defp maybe_set_default_role(attrs) do
# Only set default role if role_id is not explicitly provided
case Map.get(attrs, :role_id) || Map.get(attrs, "role_id") do
nil -> set_default_user_role(attrs)
_ -> attrs
end
end
defp set_default_user_role(attrs) do
case get_role_by_name("user") do
nil -> attrs
user_role -> put_role_id(attrs, user_role.id)
end
end
defp put_role_id(attrs, role_id) do
key = determine_role_key(attrs)
Map.put(attrs, key, role_id)
end
defp determine_role_key(attrs) do
if has_string_keys?(attrs) do
"role_id"
else
:role_id
end
end
defp has_string_keys?(attrs) do
is_map(attrs) and map_size(attrs) > 0 and
Enum.all?(Map.keys(attrs), &is_binary/1)
end
end

View File

@@ -0,0 +1,27 @@
defmodule Claper.Accounts.Guardian do
@moduledoc """
Implementation module for Guardian authentication.
This module handles JWT token generation and validation for user authentication.
"""
defmodule Plug do
@moduledoc """
Plug helpers for Guardian authentication in tests.
"""
@doc """
Sign in a user to a conn.
## Parameters
- conn: The connection
- user: The user to sign in
## Returns
- Updated conn with user signed in
"""
def sign_in(conn, user) do
# For tests, we'll just put the user in the conn assigns
Elixir.Plug.Conn.assign(conn, :current_user, user)
end
end
end

119
lib/claper/accounts/oidc.ex Normal file
View File

@@ -0,0 +1,119 @@
defmodule Claper.Accounts.Oidc do
@moduledoc """
The OIDC context for authentication and provider management.
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.Oidc.Provider
@doc """
Returns the list of oidc_providers.
## Examples
iex> list_providers()
[%Provider{}, ...]
"""
def list_providers do
Repo.all(Provider)
end
@doc """
Gets a single provider.
Raises `Ecto.NoResultsError` if the Provider does not exist.
## Examples
iex> get_provider!(123)
%Provider{}
iex> get_provider!(456)
** (Ecto.NoResultsError)
"""
def get_provider!(id), do: Repo.get!(Provider, id)
@doc """
Creates a provider.
## Examples
iex> create_provider(%{field: value})
{:ok, %Provider{}}
iex> create_provider(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_provider(attrs \\ %{}) do
%Provider{}
|> Provider.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a provider.
## Examples
iex> update_provider(provider, %{field: new_value})
{:ok, %Provider{}}
iex> update_provider(provider, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_provider(%Provider{} = provider, attrs) do
provider
|> Provider.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a provider.
## Examples
iex> delete_provider(provider)
{:ok, %Provider{}}
iex> delete_provider(provider)
{:error, %Ecto.Changeset{}}
"""
def delete_provider(%Provider{} = provider) do
Repo.delete(provider)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking provider changes.
## Examples
iex> change_provider(provider)
%Ecto.Changeset{data: %Provider{}}
"""
def change_provider(%Provider{} = provider, attrs \\ %{}) do
Provider.changeset(provider, attrs)
end
@doc """
Search providers by name or issuer.
## Examples
iex> search_providers("%example%")
[%Provider{}, ...]
"""
def search_providers(search_term) do
from(p in Provider,
where: ilike(p.name, ^search_term) or ilike(p.issuer, ^search_term)
)
|> Repo.all()
end
end

View File

@@ -0,0 +1,57 @@
defmodule Claper.Accounts.Oidc.Provider do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
name: String.t(),
issuer: String.t(),
client_id: String.t(),
client_secret: String.t(),
redirect_uri: String.t(),
scope: String.t(),
active: boolean(),
response_type: String.t(),
response_mode: String.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "oidc_providers" do
field :name, :string
field :issuer, :string
field :client_id, :string
field :client_secret, :string
field :redirect_uri, :string
field :scope, :string, default: "openid email profile"
field :active, :boolean, default: true
field :response_type, :string, default: "code"
field :response_mode, :string, default: "query"
timestamps()
end
@doc """
A changeset for creating or updating an OIDC provider.
"""
def changeset(provider, attrs) do
provider
|> cast(attrs, [
:name,
:issuer,
:client_id,
:client_secret,
:redirect_uri,
:scope,
:active,
:response_type,
:response_mode
])
|> validate_required([:name, :issuer, :client_id, :client_secret, :redirect_uri])
|> validate_format(:issuer, ~r/^https?:\/\//, message: "must start with http:// or https://")
|> validate_format(:redirect_uri, ~r/^https?:\/\//,
message: "must start with http:// or https://"
)
|> unique_constraint(:name)
end
end

View File

@@ -0,0 +1,31 @@
defmodule Claper.Accounts.Role do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
name: String.t(),
permissions: map(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "roles" do
field :name, :string
field :permissions, :map, default: %{}
has_many :users, Claper.Accounts.User
timestamps()
end
@doc """
Changeset for creating or updating a role.
"""
def changeset(role, attrs) do
role
|> cast(attrs, [:name, :permissions])
|> validate_required([:name])
|> unique_constraint(:name)
end
end

View File

@@ -13,6 +13,8 @@ defmodule Claper.Accounts.User do
confirmed_at: NaiveDateTime.t() | nil,
locale: String.t() | nil,
events: [Claper.Events.Event.t()] | nil,
role: Claper.Accounts.Role.t() | nil,
role_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t(),
deleted_at: NaiveDateTime.t() | nil
@@ -30,15 +32,18 @@ defmodule Claper.Accounts.User do
has_many :events, Claper.Events.Event
has_one :lti_user, Lti13.Users.User
belongs_to :role, Claper.Accounts.Role
has_many :quiz_responses, Claper.Quizzes.QuizResponse
timestamps()
end
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password])
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password, :role_id])
|> validate_email()
|> validate_password(opts)
|> foreign_key_constraint(:role_id)
end
def preferences_changeset(user, attrs) do
@@ -46,6 +51,17 @@ defmodule Claper.Accounts.User do
|> cast(attrs, [:locale])
end
@doc """
A changeset for admin operations on users.
"""
def admin_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :confirmed_at, :password, :role_id])
|> validate_email()
|> validate_admin_password(opts)
|> foreign_key_constraint(:role_id)
end
@doc """
A changeset for marking a user as deleted.
"""
@@ -70,6 +86,19 @@ defmodule Claper.Accounts.User do
|> maybe_hash_password(opts)
end
defp validate_admin_password(changeset, opts) do
password = get_change(changeset, :password)
# Only validate password if it's provided
if password && password != "" do
changeset
|> validate_length(:password, min: 6, max: 72)
|> maybe_hash_password(opts)
else
changeset
end
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)

View File

@@ -52,7 +52,7 @@ defmodule Claper.Accounts.UserNotifier do
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
Claper.Workers.Mailers.new_update_email(user.id, url) |> Oban.insert()
Claper.Workers.Mailers.new_update_email(user.email, url) |> Oban.insert()
{:ok, :enqueued}
end

635
lib/claper/admin.ex Normal file
View File

@@ -0,0 +1,635 @@
defmodule Claper.Admin do
@moduledoc """
The Admin context.
Provides functions for admin dashboard statistics and paginated lists of resources.
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.User
alias Claper.Events.Event
alias Claper.Accounts.Oidc.Provider
@doc """
Gets dashboard statistics.
Returns a map with counts of users, events, and upcoming events.
## Examples
iex> get_dashboard_stats()
%{users_count: 10, events_count: 20, upcoming_events: 5}
"""
def get_dashboard_stats do
users_count =
User
|> where([u], is_nil(u.deleted_at))
|> Repo.aggregate(:count, :id)
events_count =
Event
|> Repo.aggregate(:count, :id)
_now = NaiveDateTime.utc_now()
upcoming_events =
Event
|> where([e], is_nil(e.expired_at))
|> Repo.aggregate(:count, :id)
%{
users_count: users_count,
events_count: events_count,
upcoming_events: upcoming_events
}
end
@doc """
Gets users over time for analytics charts.
Returns user registration data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_users_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [2, 5, 1, 3, ...]
}
"""
def get_users_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual user counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM users
WHERE deleted_at IS NULL
AND inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
user_counts =
result.rows
|> Enum.map(fn [period, count] ->
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# Format data for charts
labels = Enum.map(date_range, &format_date_label(&1, period))
values =
Enum.map(date_range, fn date ->
Map.get(user_counts, truncate_date(date, period), 0)
end)
%{
labels: labels,
values: values
}
end
@doc """
Gets events over time for analytics charts.
Returns event creation data grouped by time period.
## Parameters
- period: :day, :week, :month (default: :day)
- days_back: number of days to look back (default: 30)
## Examples
iex> get_events_over_time(:day, 7)
%{
labels: ["2025-01-10", "2025-01-11", ...],
values: [1, 3, 0, 2, ...]
}
"""
def get_events_over_time(period \\ :day, days_back \\ 30) do
end_date = NaiveDateTime.utc_now()
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
# Generate all dates in the range
date_range = generate_date_range(start_date, end_date, period)
# Get actual event counts per period using raw SQL to avoid parameter conflicts
period_sql_value = period_sql(period)
sql = """
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
FROM events
WHERE inserted_at >= $2
AND inserted_at <= $3
GROUP BY DATE_TRUNC($1, inserted_at)
ORDER BY period
"""
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
event_counts =
result.rows
|> Enum.map(fn [period, count] ->
# Normalize the timestamp by removing microseconds
normalized_period = NaiveDateTime.truncate(period, :second)
{normalized_period, count}
end)
|> Enum.into(%{})
# Format data for charts
labels = Enum.map(date_range, &format_date_label(&1, period))
values =
Enum.map(date_range, fn date ->
Map.get(event_counts, truncate_date(date, period), 0)
end)
%{
labels: labels,
values: values
}
end
@doc """
Gets growth metrics for dashboard statistics.
Returns percentage growth for users and events compared to previous period.
"""
def get_growth_metrics do
now = NaiveDateTime.utc_now()
thirty_days_ago = NaiveDateTime.add(now, -(30 * 24 * 60 * 60), :second)
sixty_days_ago = NaiveDateTime.add(now, -(60 * 24 * 60 * 60), :second)
# Current period (last 30 days)
current_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^thirty_days_ago and u.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
current_events =
Event
|> where([e], e.inserted_at >= ^thirty_days_ago and e.inserted_at <= ^now)
|> Repo.aggregate(:count, :id)
# Previous period (30-60 days ago)
previous_users =
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^sixty_days_ago and u.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
previous_events =
Event
|> where([e], e.inserted_at >= ^sixty_days_ago and e.inserted_at < ^thirty_days_ago)
|> Repo.aggregate(:count, :id)
%{
users_growth: calculate_growth_percentage(current_users, previous_users),
events_growth: calculate_growth_percentage(current_events, previous_events)
}
end
@doc """
Gets recent activity stats for dashboard.
Returns counts of recent activities.
"""
def get_activity_stats do
now = NaiveDateTime.utc_now()
twenty_four_hours_ago = NaiveDateTime.add(now, -(24 * 60 * 60), :second)
seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second)
%{
users_today:
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^twenty_four_hours_ago)
|> Repo.aggregate(:count, :id),
events_today:
Event
|> where([e], e.inserted_at >= ^twenty_four_hours_ago)
|> Repo.aggregate(:count, :id),
users_this_week:
User
|> where([u], is_nil(u.deleted_at))
|> where([u], u.inserted_at >= ^seven_days_ago)
|> Repo.aggregate(:count, :id),
events_this_week:
Event
|> where([e], e.inserted_at >= ^seven_days_ago)
|> Repo.aggregate(:count, :id)
}
end
# Private helper functions
defp generate_date_range(start_date, end_date, period) do
start_date
|> NaiveDateTime.to_date()
|> Date.range(NaiveDateTime.to_date(end_date))
|> Enum.to_list()
|> case do
dates when period == :day ->
dates
dates when period == :week ->
dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1)
dates when period == :month ->
dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys()
end
end
defp period_sql(period) do
case period do
:day -> "day"
:week -> "week"
:month -> "month"
end
end
defp format_date_label(date, period) do
case period do
:day -> Date.to_string(date)
:week -> "Week of #{Date.to_string(date)}"
:month -> "#{Date.to_string(date) |> String.slice(0..6)}"
end
end
defp truncate_date(date, period) do
naive_date = NaiveDateTime.new!(date, ~T[00:00:00])
case period do
:day ->
NaiveDateTime.truncate(naive_date, :second)
:week ->
days_to_subtract = Date.day_of_week(date) - 1
date
|> Date.add(-days_to_subtract)
|> NaiveDateTime.new!(~T[00:00:00])
|> NaiveDateTime.truncate(:second)
:month ->
date
|> Date.beginning_of_month()
|> NaiveDateTime.new!(~T[00:00:00])
|> NaiveDateTime.truncate(:second)
end
end
defp calculate_growth_percentage(current, previous) do
cond do
previous == 0 and current > 0 ->
100.0
previous == 0 and current == 0 ->
0.0
previous > 0 ->
:erlang.float_to_binary(((current - previous) / previous * 100) |> Float.round(1),
decimals: 1
)
true ->
0.0
end
end
@doc """
Returns a paginated list of users.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of users per page (default: 20)
* `:search` - Search term for filtering users by email
* `:role` - Filter users by role name
## Examples
iex> list_users_paginated(%{page: 1, per_page: 10})
%{entries: [%User{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_users_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> join(:inner, [u], r in assoc(u, :role), on: r.name == ^role)
else
query
end
query = query |> order_by([u], desc: u.inserted_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of events.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of events per page (default: 20)
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_events_paginated(%{page: 1, per_page: 10})
%{entries: [%Event{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_events_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
status = Map.get(params, "status", "")
start_date = Map.get(params, "start_date", nil)
end_date = Map.get(params, "end_date", nil)
creator_id = Map.get(params, "creator_id", nil)
query =
Event
|> preload(:user)
query =
if search != "" do
query |> where([e], ilike(e.name, ^"%#{search}%"))
else
query
end
query =
case status do
"upcoming" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
"past" ->
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
_ ->
query
end
query =
if start_date do
query |> where([e], e.started_at >= ^start_date)
else
query
end
query =
if end_date do
query |> where([e], e.started_at <= ^end_date)
else
query
end
query =
if creator_id do
query |> where([e], e.user_id == ^creator_id)
else
query
end
query = query |> order_by([e], desc: e.started_at)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a paginated list of OIDC providers.
## Options
* `:page` - The page number (default: 1)
* `:per_page` - The number of providers per page (default: 20)
* `:search` - Search term for filtering providers by name
## Examples
iex> list_oidc_providers_paginated(%{page: 1, per_page: 10})
%{entries: [%Provider{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
"""
def list_oidc_providers_paginated(params \\ %{}) do
page = Map.get(params, "page", 1)
per_page = Map.get(params, "per_page", 20)
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.paginate(query, page: page, page_size: per_page)
end
@doc """
Returns a complete list of OIDC providers for export purposes.
Unlike the paginated version, this returns all providers matching the search criteria.
## Options
* `:search` - Search term for filtering providers by name
## Examples
iex> list_all_oidc_providers(%{search: "Google"})
[%Provider{}, ...]
"""
def list_all_oidc_providers(params \\ %{}) do
search = Map.get(params, "search", "")
query = Provider
query =
if search != "" do
query |> where([p], ilike(p.name, ^"%#{search}%"))
else
query
end
query = query |> order_by([p], p.name)
Repo.all(query)
end
@doc """
Returns a complete list of events for export purposes.
Unlike the paginated version, this returns all events matching the search criteria.
## Options
* `:search` - Search term for filtering events by name
* `:status` - Filter events by status (upcoming, past)
* `:start_date` - Filter events by start date
* `:end_date` - Filter events by end date
* `:creator_id` - Filter events by creator ID
## Examples
iex> list_all_events(%{search: "Conference"})
[%Event{}, ...]
"""
def list_all_events(params \\ %{}) do
Event
|> join(:left, [e], u in assoc(e, :user))
|> preload([e, u], user: u)
|> apply_event_search_filter(Map.get(params, "search", ""))
|> apply_event_status_filter(Map.get(params, "status", ""))
|> apply_event_start_date_filter(Map.get(params, "start_date", nil))
|> apply_event_end_date_filter(Map.get(params, "end_date", nil))
|> apply_event_creator_filter(Map.get(params, "creator_id", nil))
|> order_by([e], desc: e.started_at)
|> Repo.all()
|> Enum.map(fn event -> Map.put(event, :user_email, event.user.email) end)
end
defp apply_event_search_filter(query, ""), do: query
defp apply_event_search_filter(query, search) do
search_term = "%#{search}%"
query
|> where(
[e, u],
ilike(e.name, ^search_term) or ilike(e.code, ^search_term) or
ilike(u.email, ^search_term)
)
end
defp apply_event_status_filter(query, "upcoming") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at > ^now)
end
defp apply_event_status_filter(query, "past") do
now = NaiveDateTime.utc_now()
query |> where([e], e.started_at <= ^now)
end
defp apply_event_status_filter(query, _), do: query
defp apply_event_start_date_filter(query, nil), do: query
defp apply_event_start_date_filter(query, start_date) do
query |> where([e], e.started_at >= ^start_date)
end
defp apply_event_end_date_filter(query, nil), do: query
defp apply_event_end_date_filter(query, end_date) do
query |> where([e], e.started_at <= ^end_date)
end
defp apply_event_creator_filter(query, nil), do: query
defp apply_event_creator_filter(query, creator_id) do
query |> where([e], e.user_id == ^creator_id)
end
@doc """
Returns a complete list of users for export purposes.
Unlike the paginated version, this returns all users matching the search criteria.
## Options
* `:search` - Search term for filtering users by email or name
* `:role` - Filter users by role ID
## Examples
iex> list_all_users(%{search: "admin"})
[%User{}, ...]
"""
def list_all_users(params \\ %{}) do
search = Map.get(params, "search", "")
role = Map.get(params, "role", "")
query =
User
|> where([u], is_nil(u.deleted_at))
|> preload(:role)
query =
if search != "" do
query |> where([u], ilike(u.email, ^"%#{search}%") or ilike(u.name, ^"%#{search}%"))
else
query
end
query =
if role != "" do
query |> where([u], u.role_id == ^role)
else
query
end
query = query |> order_by([u], u.email)
# Add a virtual field for role_name to make it accessible in CSV export
Repo.all(query)
|> Enum.map(fn user ->
role_name = if user.role, do: user.role.name, else: "none"
user
|> Map.put(:role_name, role_name)
end)
end
end

View File

@@ -1,7 +1,7 @@
defmodule Claper.Embeds.Embed do
use Ecto.Schema
import Ecto.Changeset
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
@type t :: %__MODULE__{
id: integer(),
@@ -61,7 +61,7 @@ defmodule Claper.Embeds.Embed do
|> validate_format(:content, ~r/^https?:\/\/.+$/,
message: gettext("Please enter a valid link starting with http:// or https://")
)
|> validate_format(:content, ~r/youtu\.be/,
|> validate_format(:content, ~r/(youtu\.be)|(youtube\.com)/,
message: gettext("Please enter a valid %{provider} link", provider: "YouTube")
)

View File

@@ -6,8 +6,8 @@ defmodule Claper.Events do
"""
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.{Accounts, Presentations, Repo}
alias Claper.Events.{Event, ActivityLeader}
@default_page_size 5
@@ -22,7 +22,7 @@ defmodule Claper.Events do
"""
def list_events(user_id, preload \\ []) do
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.inserted_at])
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.id])
|> Repo.all()
|> Repo.preload(preload)
end
@@ -43,7 +43,7 @@ defmodule Claper.Events do
query =
from(e in Event,
where: e.user_id == ^user_id,
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
@@ -61,7 +61,7 @@ defmodule Claper.Events do
def list_not_expired_events(user_id, preload \\ []) do
from(e in Event,
where: e.user_id == ^user_id and is_nil(e.expired_at),
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
|> Repo.all()
|> Repo.preload(preload)
@@ -83,7 +83,7 @@ defmodule Claper.Events do
query =
from(e in Event,
where: e.user_id == ^user_id and is_nil(e.expired_at),
order_by: [desc: e.inserted_at]
order_by: [desc: e.id]
)
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
@@ -140,12 +140,12 @@ defmodule Claper.Events do
"""
def list_managed_events_by(email, preload \\ []) do
from(a in ActivityLeader,
join: u in Claper.Accounts.User,
join: u in Accounts.User,
on: u.email == a.email,
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email,
order_by: [desc: e.expired_at],
order_by: [desc: e.expired_at, desc: e.id],
select: e
)
|> Repo.all()
@@ -167,12 +167,12 @@ defmodule Claper.Events do
query =
from(a in ActivityLeader,
join: u in Claper.Accounts.User,
join: u in Accounts.User,
on: u.email == a.email,
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email,
order_by: [desc: e.expired_at],
order_by: [desc: e.expired_at, desc: e.id],
select: e
)
@@ -181,7 +181,7 @@ defmodule Claper.Events do
def count_managed_events_by(email) do
from(a in ActivityLeader,
join: u in Claper.Accounts.User,
join: u in Accounts.User,
on: u.email == a.email,
join: e in Event,
on: e.id == a.event_id,
@@ -193,8 +193,7 @@ defmodule Claper.Events do
def count_expired_events(user_id) do
from(e in Event,
where: e.user_id == ^user_id and not is_nil(e.expired_at),
order_by: [desc: e.expired_at]
where: e.user_id == ^user_id and not is_nil(e.expired_at)
)
|> Repo.aggregate(:count, :id)
end
@@ -207,28 +206,42 @@ defmodule Claper.Events do
from(e in Event,
where:
e.user_id == ^user_id and e.inserted_at <= ^DateTime.utc_now() and
e.inserted_at >= ^last_month,
order_by: [desc: e.id]
e.inserted_at >= ^last_month
)
|> Repo.aggregate(:count, :id)
end
@doc """
Gets a single event.
Gets a single event by serial ID or UUID.
Raises `Ecto.NoResultsError` if the Event does not exist.
## Examples
iex> get_event!(123)
%Event{}
iex> get_event!("123e4567-e89b-12d3-a456-426614174000")
%Event{}
iex> get_event!(456)
** (Ecto.NoResultsError)
iex> get_event!("123e4567-e89b-12d3-a456-4266141740111")
** (Ecto.NoResultsError)
"""
def get_event!(id, preload \\ []),
do: Repo.get_by!(Event, uuid: id) |> Repo.preload(preload)
def get_event!(id_or_uuid, preload \\ [])
def get_event!(
<<_::bytes-8, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-12>> =
uuid,
preload
),
do: Repo.get_by!(Event, uuid: uuid) |> Repo.preload(preload)
def get_event!(id, preload),
do: Repo.get!(Event, id) |> Repo.preload(preload)
@doc """
Gets a single managed event.
@@ -244,17 +257,18 @@ defmodule Claper.Events do
** (Ecto.NoResultsError)
"""
def get_managed_event!(current_user, id, preload \\ []) do
event = Repo.get_by!(Event, uuid: id)
is_leader =
Claper.Events.leaded_by?(current_user.email, event) || event.user_id == current_user.id
if is_leader do
event |> Repo.preload(preload)
else
raise Ecto.NoResultsError
end
def get_managed_event!(user, uuid, preload \\ []) do
from(
e in Event,
join: u in Accounts.User,
on: e.user_id == u.id,
left_join: a in ActivityLeader,
on: e.id == a.event_id,
where: e.uuid == ^uuid and (u.id == ^user.id or a.email == ^user.email),
select: e
)
|> Repo.one!()
|> Repo.preload(preload)
end
@doc """
@@ -304,43 +318,22 @@ defmodule Claper.Events do
|> Repo.preload(preload)
end
@doc """
Get a single event with the same code excluding a specific event.
## Examples
iex> get_different_event_with_code("Hello", 123)
%Event{}
"""
def get_different_event_with_code(nil, _event_id), do: nil
def get_different_event_with_code(code, event_id) do
now = DateTime.utc_now()
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|> Repo.one()
end
@doc """
Check if a user is a facilitator of a specific event.
## Examples
iex> leaded_by?("email@example.com", 123)
iex> led_by?("email@example.com", 123)
true
"""
def leaded_by?(email, event) do
def led_by?(email, event) do
from(a in ActivityLeader,
join: u in Claper.Accounts.User,
join: u in Accounts.User,
on: u.email == a.email,
join: e in Event,
on: e.id == a.event_id,
where: a.email == ^email and e.id == ^event.id,
order_by: [desc: e.expired_at]
where: a.email == ^email and e.id == ^event.id
)
|> Repo.exists?()
end
@@ -363,7 +356,10 @@ defmodule Claper.Events do
|> validate_unique_event()
|> case do
{:ok, event} ->
Repo.insert(event, returning: [:uuid])
with {:ok, event} <- Repo.insert(event, returning: [:uuid]) do
broadcast_all_users({:created, event})
{:ok, event}
end
{:error, changeset} ->
{:error, %{changeset | action: :insert}}
@@ -384,6 +380,15 @@ defmodule Claper.Events do
end
end
defp get_different_event_with_code(nil, _event_id), do: nil
defp get_different_event_with_code(code, event_id) do
now = DateTime.utc_now()
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|> Repo.one()
end
@doc """
Updates an event.
@@ -402,7 +407,25 @@ defmodule Claper.Events do
|> validate_unique_event()
|> case do
{:ok, event} ->
Repo.update(event, returning: [:uuid])
with {:ok, event} <- Repo.update(event, returning: [:uuid]) do
broadcast_all_users({:updated, event})
deleted_leaders =
attrs
|> Map.get("leaders", %{})
|> Map.values()
|> Enum.filter(fn
%{"delete" => "true"} -> true
_ -> false
end)
for %{"email" => leader_email} <- deleted_leaders do
leader = Accounts.get_user_by_email(leader_email)
broadcast_user_events(leader.id, {:updated, event})
end
{:ok, event}
end
{:error, changeset} ->
{:error, %{changeset | action: :update}}
@@ -424,7 +447,9 @@ defmodule Claper.Events do
|> Repo.update()
|> case do
{:ok, event} ->
broadcast({:ok, event, event.uuid}, :event_terminated)
broadcast_all_users({:updated, event})
broadcast_event(event.uuid, {:event_terminated, event.uuid})
{:ok, event}
{:error, changeset} ->
{:error, %{changeset | action: :update}}
@@ -517,7 +542,10 @@ defmodule Claper.Events do
end
@doc """
Duplicate an event
Duplicates an event.
Raises `Ecto.NoResultsError` for invalid `user_id`-`event_uuid` combinations
and returns an error tuple if any part of the transaction fails.
## Examples
@@ -527,159 +555,201 @@ defmodule Claper.Events do
iex> duplicate(user_id, event_uuid)
{:error, %Ecto.Changeset{}}
iex> duplicate(another_user_id, event_uuid)
** (Ecto.NoResultsError)
"""
def duplicate_event(user_id, event_uuid) do
case Ecto.Multi.new()
|> Ecto.Multi.run(:original_event, fn _repo, _changes ->
{:ok,
get_user_event!(user_id, event_uuid,
presentation_file: [
polls: [:poll_opts],
forms: [],
embeds: [],
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts],
presentation_state: []
]
)}
end)
|> Ecto.Multi.run(:new_event, fn _repo, %{original_event: original_event} ->
new_code =
for _ <- 1..5,
into: "",
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
original =
get_user_event!(user_id, event_uuid,
presentation_file: [
presentation_state: [],
polls: [:poll_opts],
forms: [],
embeds: [],
quizzes: [quiz_questions: [:quiz_question_opts]]
]
)
attrs =
Map.from_struct(original_event)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|> Map.put(:leaders, [])
|> Map.put(:code, "#{new_code}")
|> Map.put(:name, "#{original_event.name} (Copy)")
multi =
Ecto.Multi.new()
|> Ecto.Multi.run(:event, fn _repo, changes -> duplicate_event_attrs(original, changes) end)
|> Ecto.Multi.run(:presentation_file, fn _repo, changes ->
duplicate_presentation_file(original, changes)
end)
|> Ecto.Multi.run(:presentation_state, fn _repo, changes ->
duplicate_presentation_state(original, changes)
end)
|> Ecto.Multi.run(:polls, fn _repo, changes -> duplicate_polls(original, changes) end)
|> Ecto.Multi.run(:forms, fn _repo, changes -> duplicate_forms(original, changes) end)
|> Ecto.Multi.run(:embeds, fn _repo, changes -> duplicate_embeds(original, changes) end)
|> Ecto.Multi.run(:quizzes, fn _repo, changes -> duplicate_quizzes(original, changes) end)
create_event(attrs)
end)
|> Ecto.Multi.run(:new_presentation_file, fn _repo,
%{
original_event: original_event,
new_event: new_event
} ->
attrs =
Map.from_struct(original_event.presentation_file)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|> Map.put(:event_id, new_event.id)
Claper.Presentations.create_presentation_file(attrs)
end)
|> Ecto.Multi.run(:new_presentation_state, fn _repo,
%{
original_event: original_event,
new_presentation_file:
new_presentation_file
} ->
attrs =
Map.from_struct(original_event.presentation_file.presentation_state)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(:position, 0)
|> Map.put(:banned, [])
Claper.Presentations.create_presentation_state(attrs)
end)
|> Ecto.Multi.run(:polls, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.polls, fn poll ->
poll_attrs =
Map.from_struct(poll)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:poll_opts,
Enum.map(poll.poll_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
end)
)
{:ok, new_poll} = Claper.Polls.create_poll(poll_attrs)
new_poll
end)}
end)
|> Ecto.Multi.run(:forms, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.forms, fn form ->
form_attrs =
Map.from_struct(form)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:fields,
Enum.map(form.fields, &Map.from_struct(&1))
)
{:ok, new_form} = Claper.Forms.create_form(form_attrs)
new_form
end)}
end)
|> Ecto.Multi.run(:embeds, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.embeds, fn embed ->
embed_attrs =
Map.from_struct(embed)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
{:ok, new_embed} = Claper.Embeds.create_embed(embed_attrs)
new_embed
end)}
end)
|> Ecto.Multi.run(:quizzes, fn _repo,
%{
new_presentation_file: new_presentation_file,
original_event: original_event
} ->
{:ok,
Enum.map(original_event.presentation_file.quizzes, fn quiz ->
quiz_attrs =
Map.from_struct(quiz)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, new_presentation_file.id)
|> Map.put(
:quiz_questions,
Enum.map(quiz.quiz_questions, fn question ->
Map.from_struct(question)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(
:quiz_question_opts,
Enum.map(question.quiz_question_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:response_count, 0)
end)
)
end)
)
{:ok, new_quiz} = Claper.Quizzes.create_quiz(quiz_attrs)
new_quiz
end)}
end)
|> Repo.transaction() do
{:ok, %{new_event: new_event}} -> {:ok, new_event}
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}
case Repo.transaction(multi) do
{:ok, %{event: event}} -> {:ok, event}
{:error, _operation, value, _changes} -> {:error, value}
end
end
defp duplicate_event_attrs(original, _changes) do
code =
for _ <- 1..5,
into: "",
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
attrs =
Map.from_struct(original)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|> Map.put(:leaders, [])
|> Map.put(:code, "#{code}")
|> Map.put(:name, "#{original.name} (Copy)")
create_event(attrs)
end
defp duplicate_presentation_file(original, changes) do
case get_in(original.presentation_file) do
%Presentations.PresentationFile{} = presentation_file ->
attrs =
Map.from_struct(presentation_file)
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|> Map.put(:event_id, changes.event.id)
Presentations.create_presentation_file(attrs)
_ ->
{:ok, nil}
end
end
defp duplicate_presentation_state(original, changes) do
case get_in(original.presentation_file.presentation_state) do
%Presentations.PresentationState{} = presentation_state ->
attrs =
Map.from_struct(presentation_state)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(:position, 0)
|> Map.put(:banned, [])
Presentations.create_presentation_state(attrs)
_ ->
{:ok, nil}
end
end
defp duplicate_polls(original, changes) do
case get_in(original.presentation_file.polls) do
polls when is_list(polls) ->
polls =
for poll <- polls do
attrs =
Map.from_struct(poll)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(
:poll_opts,
Enum.map(poll.poll_opts, fn opt ->
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at, :vote_count])
end)
)
{:ok, poll} = Claper.Polls.create_poll(attrs)
poll
end
{:ok, polls}
_ ->
{:ok, nil}
end
end
defp duplicate_forms(original, changes) do
case get_in(original.presentation_file.forms) do
forms when is_list(forms) ->
forms =
for form <- forms do
attrs =
Map.from_struct(form)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(
:fields,
Enum.map(form.fields, &Map.from_struct(&1))
)
{:ok, form} = Claper.Forms.create_form(attrs)
form
end
{:ok, forms}
_ ->
{:ok, nil}
end
end
defp duplicate_embeds(original, changes) do
case get_in(original.presentation_file.embeds) do
embeds when is_list(embeds) ->
embeds =
for embed <- embeds do
attrs =
Map.from_struct(embed)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
{:ok, embed} = Claper.Embeds.create_embed(attrs)
embed
end
{:ok, embeds}
_ ->
{:ok, nil}
end
end
defp duplicate_quizzes(original, changes) do
case get_in(original.presentation_file.quizzes) do
quizzes when is_list(quizzes) ->
quizzes =
for quiz <- quizzes do
attrs =
Map.from_struct(quiz)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|> Map.put(:quiz_questions, Enum.map(quiz.quiz_questions, &map_quiz_question/1))
{:ok, quiz} = Claper.Quizzes.create_quiz(attrs)
quiz
end
{:ok, quizzes}
_ ->
{:ok, nil}
end
end
defp map_quiz_question(question) do
Map.from_struct(question)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(
:quiz_question_opts,
Enum.map(question.quiz_question_opts, &map_quiz_question_opt/1)
)
end
defp map_quiz_question_opt(opt) do
Map.from_struct(opt)
|> Map.drop([:id, :inserted_at, :updated_at])
|> Map.put(:response_count, 0)
end
@doc """
Deletes a event.
@@ -693,7 +763,20 @@ defmodule Claper.Events do
"""
def delete_event(%Event{} = event) do
Repo.delete(event)
leaders =
for %{email: email} <- get_activity_leaders_for_event(event.id) do
Accounts.get_user_by_email(email)
end
with {:ok, event} <- Repo.delete(event) do
broadcast_user_events(event.user_id, {:deleted, event})
for leader <- leaders do
broadcast_user_events(leader.id, {:deleted, event})
end
{:ok, event}
end
end
@doc """
@@ -709,8 +792,6 @@ defmodule Claper.Events do
Event.changeset(event, attrs)
end
alias Claper.Events.ActivityLeader
@doc """
Creates a activity leader.
@@ -756,7 +837,7 @@ defmodule Claper.Events do
"""
def get_activity_leaders_for_event(event_id) do
from(a in ActivityLeader,
left_join: u in Claper.Accounts.User,
left_join: u in Accounts.User,
on: u.email == a.email,
where: a.event_id == ^event_id,
select: %{a | user_id: u.id}
@@ -777,13 +858,47 @@ defmodule Claper.Events do
ActivityLeader.changeset(activity_leader, attrs)
end
defp broadcast({:ok, e, event_uuid}, event) do
Phoenix.PubSub.broadcast(
Claper.PubSub,
"event:#{event_uuid}",
{event, event_uuid}
)
@doc """
Subscribes to an event's public `Phoenix.PubSub` topic.
{:ok, e}
The broadcasted messages match the pattern:
* {:terminated, event_uuid}
"""
def subscribe_event(event_uuid) when is_binary(event_uuid) do
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_uuid}")
end
defp broadcast_event(event_uuid, message) when is_binary(event_uuid) do
Phoenix.PubSub.broadcast(Claper.PubSub, "event:#{event_uuid}", message)
end
@doc """
Subscribes to a user's events private `Phoenix.PubSub` topic.
The broadcasted messages match the pattern:
* {:created, %Event{}}
* {:updated, %Event{}}
* {:deleted, %Event{}}
"""
def subscribe_user_events(user_id) when is_integer(user_id) do
Phoenix.PubSub.subscribe(Claper.PubSub, "user:#{user_id}:events")
end
def broadcast_user_events(user_id, message) when is_integer(user_id) do
Phoenix.PubSub.broadcast(Claper.PubSub, "user:#{user_id}:events", message)
end
defp broadcast_all_users({_type, %Event{} = event} = message, _opts \\ []) do
event = Repo.preload(event, [:leaders])
broadcast_user_events(event.user_id, message)
for %{email: leader_email} <- event.leaders do
leader = Accounts.get_user_by_email(leader_email)
broadcast_user_events(leader.id, message)
end
end
end

View File

@@ -45,11 +45,12 @@ defmodule Claper.Events.Event do
:code,
:started_at,
:expired_at,
:audience_peak
:audience_peak,
:user_id
])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:code, :name])
|> validate_required([:name, :code, :started_at])
end
def create_changeset(event, attrs) do
@@ -57,7 +58,7 @@ defmodule Claper.Events.Event do
|> cast(attrs, [:name, :code, :user_id, :started_at, :expired_at])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:code, :started_at])
|> validate_required([:name, :code, :started_at, :user_id])
|> validate_length(:code, min: 5, max: 10)
|> validate_length(:name, min: 5, max: 50)
|> downcase_code
@@ -74,10 +75,10 @@ defmodule Claper.Events.Event do
def update_changeset(event, attrs) do
event
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak])
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak, :user_id])
|> cast_assoc(:presentation_file)
|> cast_assoc(:leaders)
|> validate_required([:code, :started_at])
|> validate_required([:name, :code, :started_at, :user_id])
|> validate_length(:code, min: 5, max: 10)
|> validate_length(:name, min: 5, max: 50)
|> downcase_code
@@ -90,8 +91,8 @@ defmodule Claper.Events.Event do
change(event, expired_at: expiry)
end
def subscribe(event_id) do
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_id}")
def subscribe(event_uuid) do
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_uuid}")
end
def started?(event) do

View File

@@ -4,19 +4,21 @@ defmodule Claper.Forms.Field do
@type t :: %__MODULE__{
name: String.t(),
type: String.t()
type: String.t(),
required: boolean()
}
@primary_key false
embedded_schema do
field :name, :string
field :type, :string
field :required, :boolean, default: true
end
@doc false
def changeset(form, attrs \\ %{}) do
form
|> cast(attrs, [:name, :type])
|> cast(attrs, [:name, :type, :required])
|> validate_required([:name, :type])
end
end

View File

@@ -25,6 +25,6 @@ defmodule Claper.Forms.FormSubmit do
def changeset(form_submit, attrs) do
form_submit
|> cast(attrs, [:attendee_identifier, :user_id, :form_id, :response])
|> validate_required([:form_id])
|> validate_required([:form_id, :user_id, :response])
end
end

View File

@@ -27,7 +27,11 @@ defmodule Claper.Polls.Poll do
field :show_results, :boolean
belongs_to :presentation_file, Claper.Presentations.PresentationFile
has_many :poll_opts, Claper.Polls.PollOpt, on_replace: :delete
has_many :poll_opts, Claper.Polls.PollOpt,
preload_order: [asc: :id],
on_replace: :delete
has_many :poll_votes, Claper.Polls.PollVote, on_replace: :delete
timestamps()

View File

@@ -31,6 +31,65 @@ defmodule Claper.Presentations do
def get_presentation_files_by_hash(hash) when is_nil(hash),
do: []
@doc """
Returns a list of JPG slide URLs for a given presentation.
When a `Claper.Presentations.PresentationFile{}` struct is provided, the
function builds the list of URLs programmatically from the `hash` and
`length` fields.
When an integer or binary `hash` is provided, it queries the database for the
associated presentation file and builds the list of URLs programmatically
from that.
When `nil` is provided or when no presentation file is found for the given
`hash`, it returns an empty list.
"""
def get_slide_urls(hash_or_presentation_file)
def get_slide_urls(nil), do: []
def get_slide_urls(hash) when is_integer(hash), do: get_slide_urls(to_string(hash))
def get_slide_urls(hash) when is_binary(hash) do
case Repo.get_by(PresentationFile, hash: hash) do
nil ->
[]
presentation ->
get_slide_urls(hash, presentation.length)
end
end
def get_slide_urls(%PresentationFile{} = presentation) do
get_slide_urls(presentation.hash, presentation.length)
end
@doc """
Returns a list of JPG slide URLs for a given presentation `hash` and
`length`. See also `get_slide_urls/1`.
"""
def get_slide_urls(hash, length) when is_binary(hash) and is_integer(length) do
config = Application.get_env(:claper, :presentations)
case Keyword.fetch!(config, :storage) do
"local" ->
for index <- 1..length do
"/uploads/#{hash}/#{index}.jpg"
end
"s3" ->
base_url = Keyword.fetch!(config, :s3_public_url)
for index <- 1..length do
base_url <> "/presentations/#{hash}/#{index}.jpg"
end
storage ->
raise "Unrecognised presentations storage value #{storage}"
end
end
@doc """
Creates a presentation_files.

View File

@@ -13,6 +13,7 @@ defmodule Claper.Presentations.PresentationState do
message_reaction_enabled: boolean() | nil,
banned: [String.t()] | nil,
show_only_pinned: boolean() | nil,
show_attendee_count: boolean() | nil,
presentation_file_id: integer() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
@@ -28,6 +29,7 @@ defmodule Claper.Presentations.PresentationState do
field :message_reaction_enabled, :boolean, default: true
field :banned, {:array, :string}, default: []
field :show_only_pinned, :boolean, default: false
field :show_attendee_count, :boolean, default: true
belongs_to :presentation_file, Claper.Presentations.PresentationFile
@@ -47,6 +49,7 @@ defmodule Claper.Presentations.PresentationState do
:chat_enabled,
:anonymous_chat_enabled,
:show_only_pinned,
:show_attendee_count,
:message_reaction_enabled
])
|> validate_required([])

View File

@@ -2,6 +2,8 @@ defmodule Claper.Quizzes do
import Ecto.Query, warn: false
alias Claper.Repo
alias Claper.Accounts.User
alias Claper.Quizzes.Quiz
alias Claper.Quizzes.QuizQuestion
alias Claper.Quizzes.QuizQuestionOpt
@@ -265,28 +267,62 @@ defmodule Claper.Quizzes do
{:ok, quiz}
"""
def submit_quiz(user_id, quiz_opts, quiz_id)
when is_number(user_id) and is_list(quiz_opts) do
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
Ecto.Multi.update(
multi,
{:update_quiz_opt, opt.id},
# Pattern match on user, from user we create QuizResponse struct
# def submit_quiz(user_id, quiz_opts, quiz_id)
# when is_number(user_id) and is_list(quiz_opts) do
# quiz_opts = Enum.with_index(quiz_opts)
# case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
# unique_key = "#{opt.id}_#{user_id + index}"
# multi
# |> Ecto.Multi.update(
# "update_quiz_opt_#{unique_key}",
# QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
# )
# |> Ecto.Multi.insert(
# "insert_quiz_response_#{unique_key}",
# QuizResponse.changeset(%QuizResponse{}, %{
# user_id: user_id,
# quiz_question_opt_id: opt.id,
# quiz_question_id: opt.quiz_question_id,
# quiz_id: quiz_id
# })
# )
# end)
# |> Repo.transact() do
# {:ok, _} ->
# quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
# Lti13.QuizScoreReporter.report_quiz_score(quiz, user_id)
# {:ok, quiz}
# end
# end
def submit_quiz(%User{} = user, quiz_opts, quiz_id) do
quiz_opts = Enum.with_index(quiz_opts)
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
unique_key = "#{opt.id}_#{user.id + index}"
multi
|> Ecto.Multi.update(
"update_quiz_opt_#{unique_key}",
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
)
|> Ecto.Multi.insert(
{:insert_quiz_response, opt.id},
QuizResponse.changeset(%QuizResponse{}, %{
user_id: user_id,
"insert_quiz_response_#{unique_key}",
Ecto.build_assoc(user, :quiz_responses, %{
quiz_question_opt_id: opt.id,
quiz_question_id: opt.quiz_question_id,
quiz_id: quiz_id
})
)
end)
|> Repo.transaction() do
|> Repo.transact() do
{:ok, _} ->
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
Lti13.QuizScoreReporter.report_quiz_score(quiz, user_id)
Lti13.QuizScoreReporter.report_quiz_score(quiz, user.id)
{:ok, quiz}
end
end
@@ -294,8 +330,8 @@ defmodule Claper.Quizzes do
def submit_quiz(attendee_identifier, quiz_opts, quiz_id)
when is_binary(attendee_identifier) and is_list(quiz_opts) do
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
Ecto.Multi.update(
multi,
multi
|> Ecto.Multi.update(
{:update_quiz_opt, opt.id},
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
)
@@ -309,7 +345,7 @@ defmodule Claper.Quizzes do
})
)
end)
|> Repo.transaction() do
|> Repo.transact() do
{:ok, _} ->
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
{:ok, quiz}

View File

@@ -2,6 +2,22 @@ defmodule Claper.Quizzes.Quiz do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
title: String.t(),
position: integer() | nil,
enabled: boolean(),
show_results: boolean(),
allow_anonymous: boolean(),
lti_line_item_url: String.t() | nil,
lti_resource: Lti13.Resources.Resource.t() | nil,
quiz_responses: [Claper.Quizzes.QuizResponse.t()] | nil,
quiz_questions: [Claper.Quizzes.QuizQuestion.t()] | nil,
presentation_file: Claper.Presentations.PresentationFile.t(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quizzes" do
field :title, :string
field :position, :integer, default: 0

View File

@@ -2,7 +2,17 @@ defmodule Claper.Quizzes.QuizQuestion do
use Ecto.Schema
import Ecto.Changeset
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
@type t :: %__MODULE__{
id: integer(),
content: String.t(),
type: String.t(),
quiz: Claper.Quizzes.Quiz.t() | nil,
quiz_question_opts: [Claper.Quizzes.QuizQuestionOpt.t()] | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_questions" do
field :content, :string

View File

@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizQuestionOpt do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
content: String.t(),
is_correct: boolean(),
response_count: integer(),
percentage: float() | nil,
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_question_opts" do
field :content, :string
field :is_correct, :boolean, default: false

View File

@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizResponse do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer(),
attendee_identifier: String.t() | nil,
quiz: Claper.Quizzes.Quiz.t() | nil,
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
quiz_question_opt: Claper.Quizzes.QuizQuestionOpt.t() | nil,
user: Claper.Accounts.User.t() | nil,
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
}
schema "quiz_responses" do
field :attendee_identifier, :string
@@ -20,8 +31,7 @@ defmodule Claper.Quizzes.QuizResponse do
:attendee_identifier,
:quiz_id,
:quiz_question_id,
:quiz_question_opt_id,
:user_id
:quiz_question_opt_id
])
|> validate_required([:quiz_id, :quiz_question_id, :quiz_question_opt_id])
end

View File

@@ -21,7 +21,8 @@ defmodule Claper.Release do
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn _repo ->
Code.eval_file("priv/repo/seeds.exs")
seed_script = Application.app_dir(@app, "priv/repo/seeds.exs")
Code.eval_file(seed_script)
end)
end
end

View File

@@ -4,7 +4,7 @@ defmodule Claper.Tasks.Converter do
We use a hash to identify the presentation. A new hash is generated when the conversion is finished and the presentation is being uploaded.
"""
alias ExAws.S3
alias Claper.Events
alias Porcelain.Result
@doc """
@@ -19,11 +19,7 @@ defmodule Claper.Tasks.Converter do
"status" => "progress"
})
Phoenix.PubSub.broadcast(
Claper.PubSub,
"events:#{user_id}",
{:presentation_file_process_done, presentation}
)
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
path =
Path.join([
@@ -53,11 +49,11 @@ defmodule Claper.Tasks.Converter do
)
else
stream =
ExAws.S3.list_objects(get_aws_bucket(), prefix: "presentations/#{hash}")
ExAws.S3.list_objects(get_s3_bucket(), prefix: "presentations/#{hash}")
|> ExAws.stream!()
|> Stream.map(& &1.key)
ExAws.S3.delete_all_objects(get_aws_bucket(), stream) |> ExAws.request()
ExAws.S3.delete_all_objects(get_s3_bucket(), stream) |> ExAws.request()
end
end
@@ -137,9 +133,9 @@ defmodule Claper.Tasks.Converter do
IO.puts("Uploads #{f} to presentations/#{new_hash}/#{Path.basename(f)}")
f
|> S3.Upload.stream_file()
|> S3.upload(
get_aws_bucket(),
|> ExAws.S3.Upload.stream_file()
|> ExAws.S3.upload(
get_s3_bucket(),
"presentations/#{new_hash}/#{Path.basename(f)}",
acl: "public-read"
)
@@ -167,11 +163,7 @@ defmodule Claper.Tasks.Converter do
}) do
if get_presentation_storage() != "local", do: File.rm_rf!(path)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"events:#{user_id}",
{:presentation_file_process_done, presentation}
)
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
end
end
@@ -182,11 +174,7 @@ defmodule Claper.Tasks.Converter do
}) do
File.rm_rf!(path)
Phoenix.PubSub.broadcast(
Claper.PubSub,
"events:#{user_id}",
{:presentation_file_process_done, presentation}
)
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
end
end
@@ -198,8 +186,8 @@ defmodule Claper.Tasks.Converter do
Application.get_env(:claper, :storage_dir)
end
defp get_aws_bucket do
Application.get_env(:claper, :presentations) |> Keyword.get(:aws_bucket)
defp get_s3_bucket do
Application.get_env(:claper, :presentations) |> Keyword.get(:s3_bucket)
end
defp get_resolution do

View File

@@ -6,19 +6,25 @@ defmodule Claper.Workers.Mailers do
@impl Oban.Worker
def perform(%Oban.Job{args: %{"type" => type, "user_id" => user_id, "url" => url}})
when type in ["confirm", "reset", "update_email"] do
when type in ["confirm", "reset"] do
user = Claper.Accounts.get_user!(user_id)
email =
case type do
"confirm" -> UserNotifier.confirm(user, url)
"reset" -> UserNotifier.reset(user, url)
"update_email" -> UserNotifier.update_email(user, url)
end
Mailer.deliver(email)
end
def perform(%Oban.Job{
args: %{"type" => "update_email", "new_email" => new_email, "url" => url}
}) do
email = UserNotifier.update_email(new_email, url)
Mailer.deliver(email)
end
def perform(%Oban.Job{args: %{"type" => "magic", "email" => email, "url" => url}}) do
email = UserNotifier.magic(email, url)
Mailer.deliver(email)
@@ -50,8 +56,8 @@ defmodule Claper.Workers.Mailers do
new(%{type: "reset", user_id: user_id, url: url})
end
def new_update_email(user_id, url) do
new(%{type: "update_email", user_id: user_id, url: url})
def new_update_email(new_email, url) do
new(%{type: "update_email", new_email: new_email, url: url})
end
def new_magic_link(email, url) do

View File

@@ -24,7 +24,7 @@ defmodule ClaperWeb do
use Phoenix.Controller, namespace: ClaperWeb
import Plug.Conn
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
unquote(verified_routes())
end
@@ -87,7 +87,7 @@ defmodule ClaperWeb do
quote do
use Phoenix.Channel
import Phoenix.View
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
end
end
@@ -99,7 +99,7 @@ defmodule ClaperWeb do
use PhoenixHTMLHelpers
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
import Phoenix.Component
import ClaperWeb.LiveHelpers
alias Phoenix.LiveView.JS
@@ -107,7 +107,7 @@ defmodule ClaperWeb do
import Phoenix.View
import ClaperWeb.ErrorHelpers
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
unquote(verified_routes())
end

View File

@@ -197,7 +197,7 @@ defmodule ClaperWeb.StatController do
# Private functions
defp authorize_event_access(user, event) do
if Events.leaded_by?(user.email, event) || event.user_id == user.id do
if Events.led_by?(user.email, event) || event.user_id == user.id do
:ok
else
:unauthorized

View File

@@ -103,7 +103,7 @@ defmodule ClaperWeb.UserOidcAuth do
Application.get_env(:claper, ClaperWeb.Endpoint)[:base_url]
end
defp opts(pkce_verifier \\ nil) do
defp opts(pkce_verifier) do
url = base_url()
base_opts = %{

View File

@@ -5,7 +5,7 @@ defmodule ClaperWeb.Gettext do
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import ClaperWeb.Gettext
use Gettext, backend: ClaperWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
@@ -20,5 +20,5 @@ defmodule ClaperWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :claper
use Gettext.Backend, otp_app: :claper
end

View File

@@ -0,0 +1,169 @@
defmodule ClaperWeb.Helpers.CSVExporter do
@moduledoc """
Helper module for exporting data to CSV format.
This module provides functions to convert collections of data
into CSV format for download in the admin panel.
"""
@doc """
Converts a list of records to CSV format.
## Parameters
- records: List of records/maps to convert
- headers: List of column headers
- fields: List of fields to include in the CSV
## Returns
- CSV formatted string
"""
def to_csv(records, headers, fields) do
records
|> build_rows(fields)
|> add_headers(headers)
|> CSV.encode()
|> Enum.to_list()
|> Enum.join("")
end
@doc """
Generates a timestamped filename for CSV exports.
## Parameters
- prefix: Prefix for the filename (e.g., "users", "events")
## Returns
- String filename with timestamp
"""
def generate_filename(prefix) do
date = DateTime.utc_now() |> Calendar.strftime("%Y%m%d_%H%M%S")
"#{prefix}_export_#{date}.csv"
end
# Private helper functions
defp build_rows(records, fields) do
Enum.map(records, fn record ->
Enum.map(fields, fn field ->
format_field_value(Map.get(record, field))
end)
end)
end
defp add_headers(rows, headers) do
[headers | rows]
end
defp format_field_value(value) when is_boolean(value) do
if value, do: "Yes", else: "No"
end
defp format_field_value(%DateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
end
defp format_field_value(%NaiveDateTime{} = dt) do
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
end
defp format_field_value(nil), do: ""
defp format_field_value(value), do: to_string(value)
@doc """
Exports a list of users to CSV format.
## Parameters
- users: List of User structs to export
## Returns
- CSV formatted string
"""
def export_users_to_csv(users) do
headers = ["Email", "Name", "Role", "Created At"]
# Transform users to include role name
users_with_role =
Enum.map(users, fn user ->
role_name = if user.role, do: user.role.name, else: ""
%{
email: user.email,
# Users don't have a name field currently
name: "",
role: role_name,
inserted_at: user.inserted_at
}
end)
fields = [:email, :name, :role, :inserted_at]
to_csv(users_with_role, headers, fields)
end
@doc """
Exports a list of events to CSV format.
## Parameters
- events: List of Event structs to export
## Returns
- CSV formatted string
"""
def export_events_to_csv(events) do
headers = [
"Name",
"Description",
"Start Date",
"End Date",
"Status"
]
# Transform events to include description and status
events_transformed =
Enum.map(events, fn event ->
status =
cond do
event.expired_at &&
NaiveDateTime.compare(event.expired_at, NaiveDateTime.utc_now()) == :lt ->
"completed"
event.started_at &&
NaiveDateTime.compare(event.started_at, NaiveDateTime.utc_now()) == :gt ->
"scheduled"
true ->
"active"
end
%{
name: event.name,
# Events don't have a description field currently
description: "",
start_date: event.started_at,
end_date: event.expired_at,
status: status
}
end)
fields = [:name, :description, :start_date, :end_date, :status]
to_csv(events_transformed, headers, fields)
end
@doc """
Exports a list of OIDC providers to CSV format.
## Parameters
- providers: List of Provider structs to export
## Returns
- CSV formatted string
"""
def export_oidc_providers_to_csv(providers) do
headers = ["Name", "Issuer", "Client ID", "Active"]
fields = [:name, :issuer, :client_id, :active]
to_csv(providers, headers, fields)
end
end

View File

@@ -0,0 +1,109 @@
defmodule ClaperWeb.AdminLive.DashboardLive do
use ClaperWeb, :live_view
import Ecto.Query, warn: false
alias Claper.Admin
alias Claper.Events.Event
alias Claper.Repo
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
# Set up periodic updates every 30 seconds
:timer.send_interval(30_000, self(), :update_charts)
end
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
socket =
socket
|> assign(:page_title, gettext("Dashboard"))
|> assign(:selected_period, :day)
|> assign(:days_back, 30)
|> load_dashboard_data()
{:ok, socket}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@impl true
def handle_event("change_period", %{"period" => period}, socket) do
period_atom = String.to_atom(period)
days_back =
case period_atom do
:day -> 30
# 12 weeks
:week -> 84
# 12 months
:month -> 365
_ -> 30
end
socket =
socket
|> assign(:selected_period, period_atom)
|> assign(:days_back, days_back)
|> load_chart_data()
{:noreply, socket}
end
@impl true
def handle_event("refresh_data", _params, socket) do
socket = load_dashboard_data(socket)
{:noreply, socket}
end
@impl true
def handle_info(:update_charts, socket) do
socket = load_chart_data(socket)
{:noreply, socket}
end
defp load_dashboard_data(socket) do
stats = Admin.get_dashboard_stats()
growth_metrics = Admin.get_growth_metrics()
activity_stats = Admin.get_activity_stats()
# Get recent events for the dashboard
recent_events =
Event
|> order_by([e], desc: e.started_at)
|> limit(5)
|> preload(:user)
|> Repo.all()
# Transform stats to match template expectations
transformed_stats = %{
total_users: stats.users_count,
total_events: stats.events_count,
active_events: stats.upcoming_events
}
socket
|> assign(:stats, transformed_stats)
|> assign(:growth_metrics, growth_metrics)
|> assign(:activity_stats, activity_stats)
|> assign(:recent_events, recent_events)
|> load_chart_data()
end
defp load_chart_data(socket) do
period = socket.assigns.selected_period
days_back = socket.assigns.days_back
users_chart_data = Admin.get_users_over_time(period, days_back)
events_chart_data = Admin.get_events_over_time(period, days_back)
socket
|> assign(:users_chart_data, users_chart_data)
|> assign(:events_chart_data, events_chart_data)
end
end

View File

@@ -0,0 +1,212 @@
<!-- Header -->
<div class="mb-24">
<h1 class="text-3xl font-bold">{gettext("Dashboard")}</h1>
</div>
<!-- Dashboard Content -->
<div>
<!-- Stats Cards -->
<div class="stats w-full stats-horizontal shadow mb-12">
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Total Users")}</div>
<div class="stat-value">{@stats.total_users}</div>
<div class="stat-desc">
<span class={
if @growth_metrics.users_growth > 0 do
"text-success"
else
"text-error"
end
}>
<%= if @growth_metrics.users_growth >= 0 do %>
↗︎
<% else %>
↘︎
<% end %>
{@growth_metrics.users_growth}%
</span>
{gettext("vs last month")}
</div>
</div>
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Total Events")}</div>
<div class="stat-value">{@stats.total_events}</div>
<div class="stat-desc">
<span class={
if @growth_metrics.events_growth > 0 do
"text-success"
else
"text-error"
end
}>
<%= if @growth_metrics.events_growth >= 0 do %>
↗︎ +
<% else %>
↘︎
<% end %>
{@growth_metrics.events_growth}%
</span>
{gettext("vs last month")}
</div>
</div>
<div class="stat bg-base-100">
<div class="stat-title">{gettext("Active Events")}</div>
<div class="stat-value">{@stats.active_events}</div>
<div class="stat-desc">{gettext("Currently running")}</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{gettext("User Growth")}</h2>
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
<div class="h-72 relative mt-4">
<canvas
id="userGrowthChart"
phx-hook="UserGrowthChart"
data-labels={Jason.encode!(@users_chart_data.labels)}
data-values={Jason.encode!(@users_chart_data.values)}
>
</canvas>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{gettext("Event Creation")}</h2>
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
<div class="h-72 relative mt-4">
<canvas
id="eventCreationChart"
phx-hook="EventCreationChart"
data-labels={Jason.encode!(@events_chart_data.labels)}
data-values={Jason.encode!(@events_chart_data.values)}
>
</canvas>
</div>
</div>
</div>
</div>
<!-- Recent Events Table -->
<div class="mt-12">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">{gettext("Recent Events")}</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Code")}</th>
<th>{gettext("Owner")}</th>
<th>{gettext("Start Date")}</th>
<th>{gettext("Audience Peak")}</th>
<th>{gettext("Status")}</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for event <- @recent_events do %>
<% now = NaiveDateTime.utc_now() %>
<% {status_text, status_class} =
cond do
is_nil(event.expired_at) == false ->
{gettext("Completed"), "badge-ghost"}
NaiveDateTime.compare(event.started_at, now) == :gt ->
{gettext("Scheduled"), "badge-info"}
true ->
{gettext("Active"), "badge-success"}
end %>
<tr>
<td>
<div class="font-bold">{event.name}</div>
</td>
<td>
<code class="text-sm uppercase">{event.code}</code>
</td>
<td>{event.user.email}</td>
<td>{Calendar.strftime(event.started_at, "%b %d, %Y")}</td>
<td>
<div class="badge badge-outline">{event.audience_peak}</div>
</td>
<td>
<div class={"badge #{status_class}"}>
{status_text}
</div>
</td>
<td>
<div class="flex gap-2">
<.link
navigate={~p"/admin/events/#{event}"}
class="btn btn-link btn-xs"
title={gettext("View event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/events/#{event}/edit"}
class="btn btn-link btn-xs text-primary"
title={gettext("Edit event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
</div>
</td>
</tr>
<% end %>
<%= if Enum.empty?(@recent_events) do %>
<tr>
<td colspan="7" class="text-center">
<div class="py-8 text-base-content/60">
{gettext("No events found")}
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,175 @@
defmodule ClaperWeb.AdminLive.EventLive do
use ClaperWeb, :live_view
alias Claper.Admin
alias Claper.Events.Event
alias ClaperWeb.Helpers.CSVExporter
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("Events"))
|> assign(:events, list_events())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Events")
|> assign(:event, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New event"))
|> assign(:event, %Event{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit event"))
|> assign(:event, Claper.Events.get_event!(id, [:user]))
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Event details"))
|> assign(:event, Claper.Events.get_event!(id, [:user]))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
event = Claper.Events.get_event!(id)
{:ok, _} = Claper.Events.delete_event(event)
{:noreply,
socket
|> put_flash(:info, gettext("Event deleted successfully"))
|> assign(:events, list_events())}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
events = search_events(search)
{:noreply, socket |> assign(:search, search) |> assign(:events, events)}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = String.to_existing_atom(field)
current_sort = socket.assigns.current_sort
direction =
if current_sort.field == field && current_sort.order == :asc, do: :desc, else: :asc
events = sort_events(socket.assigns.events, field, direction)
current_sort = %{field: field, order: direction}
{:noreply,
socket
|> assign(:events, events)
|> assign(:current_sort, current_sort)}
end
@impl true
def handle_info({:export_csv_requested, _params}, socket) do
filename = CSVExporter.generate_filename("events")
csv_content = CSVExporter.export_events_to_csv(socket.assigns.events)
{:noreply,
socket
|> put_flash(:info, gettext("Events exported successfully"))
|> push_event("download_csv", %{filename: filename, content: csv_content})}
end
@impl true
def handle_info({:table_action, action, event, _event_id}, socket) do
case action do
:view ->
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}")}
:edit ->
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}/edit")}
:delete ->
{:ok, _} = Claper.Events.delete_event(event)
{:noreply,
socket
|> put_flash(:info, gettext("Event deleted successfully"))
|> assign(:events, list_events())}
end
end
defp list_events do
Admin.list_all_events()
end
defp search_events(search) when search == "", do: list_events()
defp search_events(search) do
Admin.list_all_events(%{"search" => search})
end
defp sort_events(events, field, order) do
Enum.sort_by(events, &Map.get(&1, field), order)
end
def sort_indicator(assigns) do
~H"""
<%= if @current_sort.field == @field do %>
<%= if @current_sort.order == :asc do %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<% end %>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<% end %>
"""
end
end

View File

@@ -0,0 +1,367 @@
<%= case @live_action do %>
<% :index -> %>
<div phx-hook="CSVDownloader" id="event-list">
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Events")}</h1>
<div class="flex space-x-3">
<%!-- <.link navigate={~p"/admin/events/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New event")}
</.link> --%>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search events...")}
/>
</label>
</form>
</div>
<!-- events Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="name"
class="btn btn-ghost btn-sm"
>
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="code"
class="btn btn-ghost btn-sm"
>
{gettext("Code")} {sort_indicator(assigns |> Map.put(:field, :code))}
</button>
</th>
<th class="text-left">
{gettext("Owner")}
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="started_at"
class="btn btn-ghost btn-sm"
>
{gettext("Started At")} {sort_indicator(
assigns
|> Map.put(:field, :started_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="expired_at"
class="btn btn-ghost btn-sm"
>
{gettext("Expired At")} {sort_indicator(
assigns
|> Map.put(:field, :expired_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="audience_peak"
class="btn btn-ghost btn-sm"
>
{gettext("Audience Peak")} {sort_indicator(
assigns
|> Map.put(:field, :audience_peak)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@events) do %>
<tr>
<td colspan="7" class="text-center">
{gettext("No events found")}
</td>
</tr>
<% else %>
<%= for event <- @events do %>
<tr id={"event-#{event.id}"}>
<td class="font-medium">
{event.name}
</td>
<td class="uppercase">
{event.code}
</td>
<td>
{if event.user, do: event.user.email, else: gettext("No owner")}
</td>
<td>
{if event.started_at,
do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"),
else: gettext("Not started")}
</td>
<td>
{if event.expired_at,
do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"),
else: gettext("Not expired")}
</td>
<td>
<div class="badge badge-outline">
{event.audience_peak || 0}
</div>
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/events/#{event}"}
class="btn btn-link btn-sm"
title={gettext("View event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/events/#{event}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={event.id}
data-confirm={
gettext("Are you sure you want to delete this event?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete event")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Event details")}</h1>
<div class="flex gap-3">
<.link navigate={~p"/admin/events"} class="btn btn-outline btn-sm">
{gettext("Back to events")}
</.link>
<.link navigate={~p"/admin/events/#{@event}/edit"} class="btn btn-primary btn-sm">
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@event.name}</h2>
<p class="text-base-content/70 uppercase">#{@event.code}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Name")}</dt>
<dd class="col-span-2">{@event.name}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Owner")}</dt>
<dd class="col-span-2">
{if @event.user, do: @event.user.email, else: gettext("No owner")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Started At")}</dt>
<dd class="col-span-2">
{if @event.started_at,
do: Calendar.strftime(@event.started_at, "%Y-%m-%d %H:%M"),
else: gettext("Not started")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Expired At")}</dt>
<dd class="col-span-2">
{if @event.expired_at,
do: Calendar.strftime(@event.expired_at, "%Y-%m-%d %H:%M"),
else: gettext("Not expired")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Audience Peak")}</dt>
<dd class="col-span-2">
{@event.audience_peak || 0} {gettext("attendees")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New event")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.EventLive.FormComponent}
id="event-form"
title={gettext("New event")}
action={:new}
event={@event}
navigate={~p"/admin/events"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit event")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.EventLive.FormComponent}
id={"event-form-#{@event.id}"}
title={gettext("Edit event")}
action={:edit}
event={@event}
navigate={~p"/admin/events"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,160 @@
defmodule ClaperWeb.AdminLive.EventLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Events
alias Claper.Accounts
@impl true
def render(assigns) do
~H"""
<div>
<.form for={@form} id="event-form" phx-target={@myself} phx-change="validate" phx-submit="save">
<div class="grid grid-cols-6 gap-6">
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-name"
form={@form}
field={:name}
type="text"
label={gettext("Name")}
placeholder={gettext("Enter event name")}
required={true}
width_class="sm:col-span-6"
description={gettext("A unique name for this event")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-code"
form={@form}
field={:code}
type="text"
label={gettext("Code")}
placeholder={gettext("Enter event code")}
required={true}
width_class="sm:col-span-3"
field_class="uppercase"
description={gettext("A unique code for participants to join this event")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-started-at"
form={@form}
field={:started_at}
type="datetime"
label={gettext("Started At")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="event-expired-at"
form={@form}
field={:expired_at}
type="datetime"
label={gettext("Expired At")}
required={false}
width_class="sm:col-span-3"
description={gettext("When this event expires (optional)")}
/>
<.live_component
module={ClaperWeb.AdminLive.SearchableSelectComponent}
id="event-user-id"
form={@form}
field={:user_id}
label={gettext("Assigned User")}
options={@user_options}
placeholder={gettext("Search for a user...")}
required={true}
width_class="sm:col-span-6"
description={gettext("The user who owns this event (required)")}
/>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create Event"), else: gettext("Update Event")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{event: event} = assigns, socket) do
changeset = Events.change_event(event)
user_options =
Accounts.list_users()
|> Enum.map(&{"#{&1.email}", &1.id})
{:ok,
socket
|> assign(assigns)
|> assign(:user_options, user_options)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"event" => event_params}, socket) do
changeset =
socket.assigns.event
|> Events.change_event(event_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"event" => event_params}, socket) do
save_event(socket, socket.assigns.action, event_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_event(socket, :edit, event_params) do
case Events.update_event(socket.assigns.event, event_params) do
{:ok, event} ->
notify_parent({:saved, event})
{:noreply,
socket
|> put_flash(:info, gettext("Event updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_event(socket, :new, event_params) do
case Events.create_event(event_params) do
{:ok, event} ->
notify_parent({:saved, event})
{:noreply,
socket
|> put_flash(:info, gettext("Event created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -0,0 +1,184 @@
defmodule ClaperWeb.AdminLive.FormFieldComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
<div class="form-control w-full">
<label class="label">
<span class="label-text">{@label}</span>
</label>
<%= case @type do %>
<% "text" -> %>
{text_input(
@form,
@field,
[
class: "input w-full " <> @field_class,
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% "email" -> %>
{email_input(
@form,
@field,
[
class: "input w-full",
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% "password" -> %>
<div class="relative">
{password_input(
@form,
@field,
[
class: "input w-full pr-10",
placeholder: @placeholder,
required: @required,
id: "password-field-#{@field}"
] ++ @extra_attrs
)}
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-base-content/50 hover:text-base-content"
phx-click={toggle_password_visibility("password-field-#{@field}")}
>
<i class="fas fa-eye"></i>
</button>
</div>
<% "textarea" -> %>
{textarea(
@form,
@field,
[
class: "input w-full",
placeholder: @placeholder,
required: @required,
rows: @rows
] ++ @extra_attrs
)}
<% "select" -> %>
{select(
@form,
@field,
@select_options,
[
class: "select w-full",
prompt: @prompt || gettext("Select an option"),
required: @required
] ++ @extra_attrs
)}
<% "checkbox" -> %>
<div class="form-control">
<label class="label cursor-pointer justify-start">
{checkbox(
@form,
@field,
[
class: "checkbox checkbox-primary",
checked:
Phoenix.HTML.Form.input_value(@form, @field) == true ||
Phoenix.HTML.Form.input_value(@form, @field) == "true"
] ++ @extra_attrs
)}
<span class="label-text ml-2">{@checkbox_label || @label}</span>
</label>
</div>
<% "date" -> %>
{date_input(
@form,
@field,
[
class: "input input-bordered w-full",
required: @required
] ++ @extra_attrs
)}
<% "datetime" -> %>
{datetime_local_input(
@form,
@field,
[
class: "input input-bordered w-full",
required: @required
] ++ @extra_attrs
)}
<% "file" -> %>
<div class="flex items-center gap-3">
<label class="btn btn-outline btn-sm">
<span>{gettext("Choose file")}</span>
{file_input(
@form,
@field,
[
class: "sr-only",
required: @required,
phx_change: "file_selected",
phx_target: @myself
] ++ @extra_attrs
)}
</label>
<span class="text-sm text-base-content/70" id={"file-name-#{@field}"}>
{if @selected_file, do: @selected_file, else: gettext("No file chosen")}
</span>
</div>
<% _ -> %>
{text_input(
@form,
@field,
[
class: "input input-bordered w-full",
placeholder: @placeholder,
required: @required
] ++ @extra_attrs
)}
<% end %>
<label class="label">
{error_tag(@form, @field)}
<%= if @description do %>
<span class="label-text-alt">{@description}</span>
<% end %>
</label>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, selected_file: nil)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:placeholder, fn -> "" end)
|> assign_new(:required, fn -> false end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:width_class, fn -> nil end)
|> assign_new(:field_class, fn -> "" end)
|> assign_new(:checkbox_label, fn -> nil end)
|> assign_new(:prompt, fn -> nil end)
|> assign_new(:select_options, fn -> [] end)
|> assign_new(:rows, fn -> 3 end)
|> assign_new(:extra_attrs, fn -> [] end)
{:ok, socket}
end
@impl true
def handle_event("file_selected", %{"_target" => [_field_name]}, socket) do
{:noreply, assign(socket, selected_file: gettext("File selected"))}
end
defp toggle_password_visibility(field_id) do
%Phoenix.LiveView.JS{}
|> Phoenix.LiveView.JS.dispatch("toggle-password", to: "##{field_id}")
end
end

View File

@@ -0,0 +1,196 @@
defmodule ClaperWeb.AdminLive.ModalComponent do
use ClaperWeb, :live_component
alias Phoenix.LiveView.JS
@impl true
def render(assigns) do
~H"""
<div
id={@id}
class="fixed z-50 inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
role="dialog"
aria-modal="true"
phx-remove={hide_modal(@id)}
style={if @show, do: "", else: "display: none;"}
>
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
phx-click="hide"
phx-target={@myself}
>
</div>
<!-- Trick browser into centering modal contents -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<!-- Modal panel -->
<div class={[
"inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle",
@size_class
]}>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<%= if @icon do %>
<div class={[
"mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full sm:mx-0 sm:h-10 sm:w-10",
@icon_bg_class
]}>
<i class={"fas #{@icon} #{@icon_text_class}"}></i>
</div>
<% end %>
<div class={[
"mt-3 text-center sm:mt-0 sm:text-left",
if(@icon, do: "sm:ml-4", else: "w-full")
]}>
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
{@title}
</h3>
<div class="mt-2">
<%= if @description do %>
<p class="text-sm text-gray-500">
{@description}
</p>
<% end %>
<%= if @content do %>
<div class="mt-4">
{render_slot(@content)}
</div>
<% end %>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<%= if @confirm_action do %>
<button
type="button"
phx-click="confirm"
phx-target={@myself}
class={[
"w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm",
@confirm_class
]}
>
{@confirm_action}
</button>
<% end %>
<%= if @cancel_action do %>
<button
type="button"
phx-click="hide"
phx-target={@myself}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
{@cancel_action}
</button>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, show: false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show, fn -> false end)
|> assign_new(:icon, fn -> nil end)
|> assign_new(:icon_bg_class, fn -> "bg-red-100" end)
|> assign_new(:icon_text_class, fn -> "text-red-600" end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:content, fn -> [] end)
|> assign_new(:confirm_action, fn -> nil end)
|> assign_new(:confirm_class, fn -> "bg-red-600 hover:bg-red-700 focus:ring-red-500" end)
|> assign_new(:cancel_action, fn -> "Cancel" end)
|> assign_new(:custom_actions, fn -> [] end)
|> assign_new(:size_class, fn -> "sm:max-w-lg sm:w-full" end)
{:ok, socket}
end
@impl true
def handle_event("hide", _params, socket) do
send(self(), {:modal_cancelled, socket.assigns.id})
{:noreply, assign(socket, show: false)}
end
def handle_event("confirm", _params, socket) do
send(self(), {:modal_confirmed, socket.assigns.id})
{:noreply, assign(socket, show: false)}
end
# Public API for controlling the modal
def show_modal(js \\ %JS{}, modal_id) do
js
|> JS.show(to: "##{modal_id}")
|> JS.add_class("animate-fade-in", to: "##{modal_id}")
end
def hide_modal(js \\ %JS{}, modal_id) do
js
|> JS.add_class("animate-fade-out", to: "##{modal_id}")
|> JS.hide(to: "##{modal_id}", transition: "animate-fade-out", time: 200)
end
# Preset configurations for common modal types
def delete_modal_config(title, description) do
%{
icon: "fa-exclamation-triangle",
icon_bg_class: "bg-red-100",
icon_text_class: "text-red-600",
title: title,
description: description,
confirm_action: "Delete",
confirm_class: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
cancel_action: "Cancel"
}
end
def warning_modal_config(title, description) do
%{
icon: "fa-exclamation-triangle",
icon_bg_class: "bg-yellow-100",
icon_text_class: "text-yellow-600",
title: title,
description: description,
confirm_action: "Continue",
confirm_class: "bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500",
cancel_action: "Cancel"
}
end
def info_modal_config(title, description) do
%{
icon: "fa-info-circle",
icon_bg_class: "bg-blue-100",
icon_text_class: "text-blue-600",
title: title,
description: description,
confirm_action: "OK",
confirm_class: "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500",
cancel_action: nil
}
end
end

View File

@@ -0,0 +1,155 @@
defmodule ClaperWeb.AdminLive.OidcProviderLive do
use ClaperWeb, :live_view
alias Claper.Accounts.Oidc
alias Claper.Accounts.Oidc.Provider
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("OIDC Providers"))
|> assign(:providers, list_providers())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "OIDC Providers")
|> assign(:provider, nil)
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Provider details"))
|> assign(:provider, Oidc.get_provider!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New provider"))
|> assign(:provider, %Provider{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit provider"))
|> assign(:provider, Oidc.get_provider!(id))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
provider = Oidc.get_provider!(id)
{:ok, _} = Oidc.delete_provider(provider)
{:noreply,
socket
|> put_flash(:info, gettext("Provider deleted successfully"))
|> assign(:providers, list_providers())}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
providers = search_providers(search)
{:noreply, socket |> assign(:search, search) |> assign(:providers, providers)}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
%{current_sort: %{field: current_field, order: current_order}} = socket.assigns
{field, order} =
if current_field == String.to_existing_atom(field) do
{current_field, if(current_order == :asc, do: :desc, else: :asc)}
else
{String.to_existing_atom(field), :asc}
end
providers = sort_providers(socket.assigns.providers, field, order)
{:noreply,
socket
|> assign(:providers, providers)
|> assign(:current_sort, %{field: field, order: order})}
end
@impl true
def handle_info(
{ClaperWeb.AdminLive.OidcProviderLive.FormComponent, {:saved, _provider}},
socket
) do
{:noreply, assign(socket, :providers, list_providers())}
end
defp list_providers do
Oidc.list_providers()
end
defp search_providers(search) when search == "", do: list_providers()
defp search_providers(search) do
search_term = "%#{search}%"
Oidc.search_providers(search_term)
end
defp sort_providers(providers, field, order) do
Enum.sort_by(providers, &Map.get(&1, field), order)
end
def sort_indicator(assigns) do
~H"""
<%= if @current_sort.field == @field do %>
<%= if @current_sort.order == :asc do %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<% end %>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<% end %>
"""
end
end

View File

@@ -0,0 +1,358 @@
<%= case @live_action do %>
<% :index -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("OIDC Providers")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/oidc_providers/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New provider")}
</.link>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search providers...")}
/>
</label>
</form>
</div>
<!-- Providers Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="name"
class="btn btn-ghost btn-sm"
>
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="issuer"
class="btn btn-ghost btn-sm"
>
{gettext("Issuer")} {sort_indicator(assigns |> Map.put(:field, :issuer))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="active"
class="btn btn-ghost btn-sm"
>
{gettext("Status")} {sort_indicator(assigns |> Map.put(:field, :active))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="inserted_at"
class="btn btn-ghost btn-sm"
>
{gettext("Created")} {sort_indicator(
assigns
|> Map.put(:field, :inserted_at)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@providers) do %>
<tr>
<td colspan="5" class="text-center">
{gettext("No providers found")}
</td>
</tr>
<% else %>
<%= for provider <- @providers do %>
<tr id={"provider-#{provider.id}"}>
<td class="font-medium">
{provider.name}
</td>
<td>
{provider.issuer}
</td>
<td>
<%= if provider.active do %>
<span class="badge badge-success">
{gettext("Active")}
</span>
<% else %>
<span class="badge badge-ghost">
{gettext("Inactive")}
</span>
<% end %>
</td>
<td>
{Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/oidc_providers/#{provider}"}
class="btn btn-link btn-sm"
title={gettext("View provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/oidc_providers/#{provider}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={provider.id}
data-confirm={
gettext("Are you sure you want to delete this provider?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete provider")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("OIDC Provider details")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/oidc_providers"} class="btn btn-outline btn-sm">
{gettext("Back to providers")}
</.link>
<.link
navigate={~p"/admin/oidc_providers/#{@provider}/edit"}
class="btn btn-primary btn-sm"
>
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@provider.name}</h2>
<p class="text-base-content/70">{gettext("OIDC Provider")}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Name")}</dt>
<dd class="col-span-2">{@provider.name}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Issuer")}</dt>
<dd class="col-span-2">
{@provider.issuer}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Client ID")}</dt>
<dd class="col-span-2">
{@provider.client_id}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Response Type")}</dt>
<dd class="col-span-2">
{@provider.response_type}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Scope")}</dt>
<dd class="col-span-2">
{@provider.scope}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Status")}</dt>
<dd class="col-span-2">
<%= if @provider.active do %>
<span class="badge badge-success">
{gettext("Active")}
</span>
<% else %>
<span class="badge badge-ghost">
{gettext("Inactive")}
</span>
<% end %>
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New provider")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
id="provider-form"
title={gettext("New provider")}
action={:new}
provider={@provider}
navigate={~p"/admin/oidc_providers"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit OIDC Provider")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
id={"provider-form-#{@provider.id}"}
title={gettext("Edit provider")}
action={:edit}
provider={@provider}
navigate={~p"/admin/oidc_providers"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,222 @@
defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Accounts.Oidc
@impl true
def render(assigns) do
~H"""
<div>
<.form
for={@form}
id="provider-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="grid grid-cols-6 gap-6">
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="name-field"
form={@form}
field={:name}
type="text"
label={gettext("Name")}
placeholder={gettext("Enter provider name")}
required={true}
width_class="sm:col-span-6"
description={gettext("A unique name to identify this OIDC provider")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="issuer-field"
form={@form}
field={:issuer}
type="text"
label={gettext("Issuer URL")}
placeholder={gettext("https://example.com")}
required={true}
width_class="sm:col-span-6"
description={gettext("The OIDC issuer URL (must start with http:// or https://)")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="client_id-field"
form={@form}
field={:client_id}
type="text"
label={gettext("Client ID")}
placeholder={gettext("Enter client ID")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="client_secret-field"
form={@form}
field={:client_secret}
type="text"
label={gettext("Client Secret")}
placeholder={gettext("Enter client secret")}
required={true}
width_class="sm:col-span-3"
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="redirect_uri-field"
form={@form}
field={:redirect_uri}
type="text"
label={gettext("Redirect URI")}
placeholder={gettext("https://yourapp.com/auth/callback")}
required={true}
width_class="sm:col-span-6"
description={
gettext("The callback URL for your application (must start with http:// or https://)")
}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="scope-field"
form={@form}
field={:scope}
type="text"
label={gettext("Scope")}
placeholder={gettext("openid email profile")}
width_class="sm:col-span-3"
description={gettext("OIDC scopes to request (defaults to 'openid email profile')")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="response_type-field"
form={@form}
field={:response_type}
type="select"
label={gettext("Response Type")}
select_options={[
{gettext("Authorization Code"), "code"},
{gettext("Implicit"), "token"},
{gettext("Hybrid"), "code token"}
]}
width_class="sm:col-span-3"
description={gettext("OAuth 2.0 response type (defaults to 'code')")}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="response_mode-field"
form={@form}
field={:response_mode}
type="select"
label={gettext("Response Mode")}
select_options={[
{gettext("Query"), "query"},
{gettext("Fragment"), "fragment"},
{gettext("Form Post"), "form_post"}
]}
width_class="sm:col-span-3"
description={
gettext("How the authorization response should be returned (defaults to 'query')")
}
/>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="active-field"
form={@form}
field={:active}
type="checkbox"
label={gettext("Active")}
checkbox_label={gettext("Enable this OIDC provider")}
width_class="sm:col-span-3"
description={
gettext("Whether this provider is currently active and available for authentication")
}
/>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create Provider"), else: gettext("Update Provider")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{provider: provider} = assigns, socket) do
changeset = Oidc.change_provider(provider)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"provider" => provider_params}, socket) do
changeset =
socket.assigns.provider
|> Oidc.change_provider(provider_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"provider" => provider_params}, socket) do
save_provider(socket, socket.assigns.action, provider_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_provider(socket, :edit, provider_params) do
case Oidc.update_provider(socket.assigns.provider, provider_params) do
{:ok, provider} ->
notify_parent({:saved, provider})
{:noreply,
socket
|> put_flash(:info, gettext("Provider updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_provider(socket, :new, provider_params) do
case Oidc.create_provider(provider_params) do
{:ok, provider} ->
notify_parent({:saved, provider})
{:noreply,
socket
|> put_flash(:info, gettext("Provider created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -0,0 +1,175 @@
defmodule ClaperWeb.AdminLive.SearchFilterComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class="bg-white px-4 py-5 border-b border-gray-200 sm:px-6">
<div class="-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap">
<div class="ml-4 mt-2">
<form phx-submit="search" phx-target={@myself} class="flex items-center">
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-gray-400"></i>
</div>
<input
type="text"
name="search"
value={@search_value || ""}
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md"
placeholder={@search_placeholder || "Search..."}
phx-debounce="300"
phx-change="search_change"
phx-target={@myself}
/>
</div>
<%= if @filters && length(@filters) > 0 do %>
<div class="ml-3 flex space-x-2">
<%= for filter <- @filters do %>
<select
name={filter.name}
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
phx-change="filter_change"
phx-target={@myself}
phx-value-filter={filter.name}
>
<option
disabled={!@filter_values[filter.name]}
selected={!@filter_values[filter.name]}
>
{filter.label}
</option>
<%= for {label, value} <- filter.options do %>
<option value={value} selected={@filter_values[filter.name] == value}>
{label}
</option>
<% end %>
</select>
<% end %>
</div>
<% end %>
<button
type="submit"
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Search
</button>
<%= if @show_clear and (@search_value || has_active_filters?(@filter_values)) do %>
<button
type="button"
phx-click="clear_all"
phx-target={@myself}
class="ml-2 inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Clear
</button>
<% end %>
</form>
</div>
<div class="ml-4 mt-2 flex-shrink-0">
<%= if @export_csv_enabled do %>
<button
type="button"
phx-click="export_csv"
phx-target={@myself}
class="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-file-csv mr-2"></i> Export CSV
</button>
<% end %>
<%= if @new_path do %>
<.link
navigate={@new_path}
class="ml-3 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<i class="fas fa-plus mr-2"></i>
{@new_label || "New"}
</.link>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, search_value: "", filter_values: %{})}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:search_placeholder, fn -> "Search..." end)
|> assign_new(:filters, fn -> [] end)
|> assign_new(:filter_values, fn -> %{} end)
|> assign_new(:search_value, fn -> "" end)
|> assign_new(:show_clear, fn -> true end)
|> assign_new(:export_csv_enabled, fn -> false end)
|> assign_new(:new_path, fn -> nil end)
|> assign_new(:new_label, fn -> "New" end)
|> assign_new(:custom_actions, fn -> [] end)
{:ok, socket}
end
@impl true
def handle_event("search", %{"search" => search_value}, socket) do
send(
self(),
{:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}
)
{:noreply, assign(socket, search_value: search_value)}
end
def handle_event("search_change", %{"search" => search_value}, socket) do
send(
self(),
{:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}
)
{:noreply, assign(socket, search_value: search_value)}
end
def handle_event("filter_change", %{"filter" => filter_name, "value" => filter_value}, socket) do
filter_values = Map.put(socket.assigns.filter_values, filter_name, filter_value)
send(
self(),
{:search_filter_changed, %{search: socket.assigns.search_value, filters: filter_values}}
)
{:noreply, assign(socket, filter_values: filter_values)}
end
def handle_event("clear_all", _params, socket) do
send(self(), {:search_filter_changed, %{search: "", filters: %{}}})
{:noreply, assign(socket, search_value: "", filter_values: %{})}
end
def handle_event("export_csv", _params, socket) do
send(
self(),
{:export_csv_requested,
%{search: socket.assigns.search_value, filters: socket.assigns.filter_values}}
)
{:noreply, socket}
end
defp has_active_filters?(filter_values) do
Enum.any?(filter_values, fn {_key, value} -> value != nil and value != "" end)
end
end

View File

@@ -0,0 +1,170 @@
defmodule ClaperWeb.AdminLive.SearchableSelectComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
<div class="form-control w-full">
<label class="label">
<span class="label-text">{@label}</span>
</label>
<div class="dropdown dropdown-bottom w-full">
<input
type="text"
value={@search_term}
placeholder={@placeholder}
class="input input-bordered w-full"
phx-keyup="search"
phx-focus="open_dropdown"
phx-target={@myself}
phx-debounce="300"
autocomplete="off"
tabindex="0"
/>
<input
type="hidden"
name={"#{@form.name}[#{@field}]"}
value={@selected_value || ""}
id={"#{@form.name}_#{@field}"}
/>
<%= if @show_dropdown and length(@filtered_options) > 0 do %>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-full p-2 shadow"
phx-click-away="close_dropdown"
phx-target={@myself}
>
<%= for {label, value} <- @filtered_options do %>
<li class="w-full">
<a
class={if to_string(value) == to_string(@selected_value), do: "active", else: ""}
phx-click="select_option"
phx-value-value={value}
phx-value-label={label}
phx-target={@myself}
>
{label}
</a>
</li>
<% end %>
</ul>
<% end %>
</div>
<label class="label">
{error_tag(@form, @field)}
<%= if @description do %>
<span class="label-text-alt">{@description}</span>
<% end %>
</label>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:show_dropdown, false)
|> assign(:search_term, "")
|> assign(:filtered_options, [])
|> assign(:selected_value, nil)
|> assign(:display_value, "")}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:placeholder, fn -> gettext("Select...") end)
|> assign_new(:required, fn -> false end)
|> assign_new(:description, fn -> nil end)
|> assign_new(:width_class, fn -> nil end)
|> assign_new(:options, fn -> [] end)
|> update_filtered_options()
|> update_display_value()
{:ok, socket}
end
@impl true
def handle_event("search", %{"value" => search_term}, socket) do
socket =
socket
|> assign(:search_term, search_term)
|> assign(:show_dropdown, true)
|> update_filtered_options()
{:noreply, socket}
end
def handle_event("select_option", %{"value" => value, "label" => label}, socket) do
socket =
socket
|> assign(:selected_value, value)
|> assign(:display_value, label)
|> assign(:show_dropdown, false)
|> assign(:search_term, label)
{:noreply, socket}
end
def handle_event("open_dropdown", _params, socket) do
socket =
socket
|> assign(:show_dropdown, true)
|> update_filtered_options()
{:noreply, socket}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :show_dropdown, false)}
end
defp update_filtered_options(socket) do
search_term = String.downcase(socket.assigns[:search_term] || "")
filtered =
if search_term == "" do
socket.assigns.options
else
Enum.filter(socket.assigns.options, fn {label, _value} ->
String.contains?(String.downcase(label), search_term)
end)
end
assign(socket, :filtered_options, filtered)
end
defp update_display_value(socket) do
current_value = get_field_value(socket.assigns.form, socket.assigns.field)
display_value = find_display_value(current_value, socket.assigns.options)
socket
|> assign(:selected_value, current_value)
|> assign(:display_value, display_value)
|> assign(:search_term, display_value)
end
defp find_display_value(nil, _options), do: ""
defp find_display_value(current_value, options) do
case Enum.find(options, fn {_label, value} ->
to_string(value) == to_string(current_value)
end) do
{label, _value} -> label
nil -> ""
end
end
defp get_field_value(form, field) do
Map.get(form.data, field) || Map.get(form.params, to_string(field))
end
end

View File

@@ -0,0 +1,242 @@
defmodule ClaperWeb.AdminLive.TableActionsComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class="flex items-center space-x-2">
<%= if @view_enabled do %>
<button
type="button"
phx-click="view_item"
phx-target={@myself}
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
title="View"
>
<i class="fas fa-eye"></i>
<span class="sr-only">View</span>
</button>
<% end %>
<%= if @edit_enabled do %>
<button
type="button"
phx-click="edit_item"
phx-target={@myself}
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
title="Edit"
>
<i class="fas fa-edit"></i>
<span class="sr-only">Edit</span>
</button>
<% end %>
<%= if @delete_enabled do %>
<button
type="button"
phx-click="delete_item"
phx-target={@myself}
class="text-red-600 hover:text-red-900 transition-colors duration-200"
title="Delete"
data-confirm={
@delete_confirm_message ||
"Are you sure you want to delete this item? This action cannot be undone."
}
>
<i class="fas fa-trash-alt"></i>
<span class="sr-only">Delete</span>
</button>
<% end %>
<%= if @duplicate_enabled do %>
<button
type="button"
phx-click="duplicate_item"
phx-target={@myself}
class="text-green-600 hover:text-green-900 transition-colors duration-200"
title="Duplicate"
>
<i class="fas fa-copy"></i>
<span class="sr-only">Duplicate</span>
</button>
<% end %>
<%= if @archive_enabled do %>
<button
type="button"
phx-click="archive_item"
phx-target={@myself}
class={[
"transition-colors duration-200",
if(@item_archived,
do: "text-orange-600 hover:text-orange-900",
else: "text-gray-600 hover:text-gray-900"
)
]}
title={if @item_archived, do: "Unarchive", else: "Archive"}
>
<i class={if @item_archived, do: "fas fa-box-open", else: "fas fa-archive"}></i>
<span class="sr-only">{if @item_archived, do: "Unarchive", else: "Archive"}</span>
</button>
<% end %>
<%= if @toggle_enabled do %>
<button
type="button"
phx-click="toggle_item"
phx-target={@myself}
class={[
"transition-colors duration-200",
if(@item_active,
do: "text-green-600 hover:text-green-900",
else: "text-gray-600 hover:text-gray-900"
)
]}
title={
if @item_active,
do: @toggle_active_title || "Deactivate",
else: @toggle_inactive_title || "Activate"
}
>
<i class={if @item_active, do: "fas fa-toggle-on", else: "fas fa-toggle-off"}></i>
<span class="sr-only">
{if @item_active,
do: @toggle_active_title || "Deactivate",
else: @toggle_inactive_title || "Activate"}
</span>
</button>
<% end %>
<%= if @dropdown_actions && length(@dropdown_actions) > 0 do %>
<div class="relative" phx-click-away="close_dropdown" phx-target={@myself}>
<button
type="button"
phx-click="toggle_dropdown"
phx-target={@myself}
class="text-gray-600 hover:text-gray-900 transition-colors duration-200"
title="More actions"
>
<i class="fas fa-ellipsis-v"></i>
<span class="sr-only">More actions</span>
</button>
<%= if @dropdown_open do %>
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 border border-gray-200">
<div class="py-1">
<%= for action <- @dropdown_actions do %>
<button
type="button"
phx-click="dropdown_action"
phx-value-action={action.key}
phx-target={@myself}
class={[
"block w-full text-left px-4 py-2 text-sm transition-colors duration-200",
case action.type do
"danger" -> "text-red-700 hover:bg-red-50"
"warning" -> "text-orange-700 hover:bg-orange-50"
_ -> "text-gray-700 hover:bg-gray-50"
end
]}
data-confirm={action[:confirm]}
>
<%= if action[:icon] do %>
<i class={"#{action.icon} mr-2"}></i>
<% end %>
{action.label}
</button>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
<%= if @custom_actions do %>
{render_slot(@custom_actions)}
<% end %>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, dropdown_open: false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:view_enabled, fn -> false end)
|> assign_new(:edit_enabled, fn -> true end)
|> assign_new(:delete_enabled, fn -> true end)
|> assign_new(:duplicate_enabled, fn -> false end)
|> assign_new(:archive_enabled, fn -> false end)
|> assign_new(:toggle_enabled, fn -> false end)
|> assign_new(:item_archived, fn -> false end)
|> assign_new(:item_active, fn -> true end)
|> assign_new(:delete_confirm_message, fn -> nil end)
|> assign_new(:toggle_active_title, fn -> nil end)
|> assign_new(:toggle_inactive_title, fn -> nil end)
|> assign_new(:dropdown_actions, fn -> [] end)
|> assign_new(:custom_actions, fn -> [] end)
|> assign_new(:dropdown_open, fn -> false end)
{:ok, socket}
end
@impl true
def handle_event("view_item", _params, socket) do
send(self(), {:table_action, :view, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("edit_item", _params, socket) do
send(self(), {:table_action, :edit, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("delete_item", _params, socket) do
send(self(), {:table_action, :delete, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("duplicate_item", _params, socket) do
send(self(), {:table_action, :duplicate, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("archive_item", _params, socket) do
action = if socket.assigns.item_archived, do: :unarchive, else: :archive
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("toggle_item", _params, socket) do
action = if socket.assigns.item_active, do: :deactivate, else: :activate
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
{:noreply, socket}
end
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, dropdown_open: !socket.assigns.dropdown_open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, dropdown_open: false)}
end
def handle_event("dropdown_action", %{"action" => action_key}, socket) do
action = Enum.find(socket.assigns.dropdown_actions, &(&1.key == action_key))
if action do
send(
self(),
{:table_action, String.to_atom(action_key), socket.assigns.item, socket.assigns.item_id}
)
end
{:noreply, assign(socket, dropdown_open: false)}
end
end

View File

@@ -0,0 +1,282 @@
defmodule ClaperWeb.AdminLive.TableComponent do
use ClaperWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<%= for {header, _index} <- Enum.with_index(@headers) do %>
<th
scope="col"
class={[
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
if(@sortable && header.sortable, do: "cursor-pointer hover:bg-gray-100", else: "")
]}
phx-click={if @sortable && header.sortable, do: "sort", else: nil}
phx-value-field={if @sortable && header.sortable, do: header.field, else: nil}
phx-target={@myself}
>
<div class="flex items-center">
{if is_binary(header), do: header, else: header.label}
<%= if @sortable && header.sortable do %>
<%= case @sort_config do %>
<% %{field: field, direction: :asc} when field == header.field -> %>
<i class="fas fa-sort-up ml-2 text-indigo-500"></i>
<% %{field: field, direction: :desc} when field == header.field -> %>
<i class="fas fa-sort-down ml-2 text-indigo-500"></i>
<% _ -> %>
<i class="fas fa-sort ml-2 text-gray-400"></i>
<% end %>
<% end %>
</div>
</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<%= if length(@rows) > 0 do %>
<%= for {row, row_index} <- Enum.with_index(@rows) do %>
<tr
class={[
"hover:bg-gray-50",
if(@row_click_enabled, do: "cursor-pointer", else: "")
]}
phx-click={if @row_click_enabled, do: "row_clicked", else: nil}
phx-value-row-index={row_index}
phx-target={@myself}
>
<%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= case cell_content do %>
<% {:safe, content} -> %>
{raw(content)}
<% content when is_binary(content) -> %>
{content}
<% content -> %>
{to_string(content)}
<% end %>
</td>
<% end %>
</tr>
<% end %>
<% else %>
<tr>
<td colspan={length(@headers)} class="px-6 py-4 text-center text-sm text-gray-500">
<div class="flex flex-col items-center py-8">
<%= if @empty_icon do %>
<i class={"#{@empty_icon} text-gray-300 text-4xl mb-4"}></i>
<% end %>
<p class="text-lg font-medium text-gray-900 mb-2">
{@empty_title || "No items found"}
</p>
<p class="text-gray-500">
{@empty_message || "There are no items to display."}
</p>
<%= if @empty_action do %>
<div class="mt-4">
{render_slot(@empty_action)}
</div>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @pagination do %>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<%= if @pagination.page_number > 1 do %>
<button
type="button"
phx-click="paginate"
phx-value-page={@pagination.page_number - 1}
phx-target={@myself}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Previous
</button>
<% else %>
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
Previous
</span>
<% end %>
<%= if @pagination.page_number < @pagination.total_pages do %>
<button
type="button"
phx-click="paginate"
phx-value-page={@pagination.page_number + 1}
phx-target={@myself}
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Next
</button>
<% else %>
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
Next
</span>
<% end %>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium">
{(@pagination.page_number - 1) * @pagination.page_size + 1}
</span>
to
<span class="font-medium">
{min(@pagination.page_number * @pagination.page_size, @pagination.total_entries)}
</span>
of <span class="font-medium">{@pagination.total_entries}</span>
results
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<%= if @pagination.page_number > 1 do %>
<button
type="button"
phx-click="paginate"
phx-value-page={@pagination.page_number - 1}
phx-target={@myself}
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left"></i>
</button>
<% else %>
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left"></i>
</span>
<% end %>
<%= for page <- get_page_range(@pagination) do %>
<%= if page == @pagination.page_number do %>
<span class="relative inline-flex items-center px-4 py-2 border border-indigo-500 bg-indigo-50 text-sm font-medium text-indigo-600">
{page}
</span>
<% else %>
<button
type="button"
phx-click="paginate"
phx-value-page={page}
phx-target={@myself}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{page}
</button>
<% end %>
<% end %>
<%= if @pagination.page_number < @pagination.total_pages do %>
<button
type="button"
phx-click="paginate"
phx-value-page={@pagination.page_number + 1}
phx-target={@myself}
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right"></i>
</button>
<% else %>
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right"></i>
</span>
<% end %>
</nav>
</div>
</div>
</div>
<% end %>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, assign(socket, sort_config: %{field: nil, direction: :asc})}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:sortable, fn -> false end)
|> assign_new(:sort_config, fn -> %{field: nil, direction: :asc} end)
|> assign_new(:row_click_enabled, fn -> false end)
|> assign_new(:empty_title, fn -> nil end)
|> assign_new(:empty_message, fn -> nil end)
|> assign_new(:empty_icon, fn -> nil end)
|> assign_new(:empty_action, fn -> [] end)
|> assign_new(:pagination, fn -> nil end)
|> assign_new(:row_func, fn -> nil end)
{:ok, socket}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
current_sort = socket.assigns.sort_config
new_direction =
if current_sort.field == field and current_sort.direction == :asc do
:desc
else
:asc
end
sort_config = %{field: field, direction: new_direction}
send(self(), {:table_sort_changed, sort_config})
{:noreply, assign(socket, sort_config: sort_config)}
end
def handle_event("paginate", %{"page" => page}, socket) do
page_number = String.to_integer(page)
send(self(), {:table_page_changed, page_number})
{:noreply, socket}
end
def handle_event("row_clicked", %{"row-index" => row_index}, socket) do
index = String.to_integer(row_index)
row = Enum.at(socket.assigns.rows, index)
send(self(), {:table_row_clicked, row, index})
{:noreply, socket}
end
defp get_row_cells(row, headers, nil) do
# Default behavior: assume row is a list/tuple matching header count
case row do
row when is_list(row) -> row
row when is_tuple(row) -> Tuple.to_list(row)
_ -> List.duplicate("", length(headers))
end
end
defp get_row_cells(row, _headers, row_func) when is_function(row_func) do
row_func.(row)
end
defp get_page_range(pagination) do
start_page = max(1, pagination.page_number - 2)
end_page = min(pagination.total_pages, pagination.page_number + 2)
start_page..end_page
end
end

View File

@@ -0,0 +1,192 @@
defmodule ClaperWeb.AdminLive.UserLive do
use ClaperWeb, :live_view
alias Claper.Admin
alias Claper.Accounts
alias Claper.Accounts.User
alias ClaperWeb.Helpers.CSVExporter
@impl true
def mount(_params, session, socket) do
with %{"locale" => locale} <- session do
Gettext.put_locale(ClaperWeb.Gettext, locale)
end
{:ok,
socket
|> assign(:page_title, gettext("Users"))
|> assign(:users, list_users())
|> assign(:search, "")
|> assign(:current_sort, %{field: :na, order: :asc})}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Users")
|> assign(:user, nil)
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New user"))
|> assign(:user, %User{})
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit user"))
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
end
defp apply_action(socket, :show, %{"id" => id}) do
socket
|> assign(:page_title, gettext("User details"))
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
{:ok, _} = Accounts.delete_user(user)
{:noreply,
socket
|> put_flash(:info, gettext("User deleted successfully"))
|> assign(:users, list_users())}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_sort = socket.assigns.current_sort
new_direction =
if current_sort.field == field_atom do
if current_sort.order == :asc, do: :desc, else: :asc
else
:asc
end
users = sort_users(socket.assigns.users, field_atom, new_direction)
current_sort = %{field: field_atom, order: new_direction}
{:noreply,
socket
|> assign(:users, users)
|> assign(:current_sort, current_sort)}
end
@impl true
def handle_info({:search_filter_changed, %{search: search, filters: _filters}}, socket) do
users = search_users(search)
{:noreply, socket |> assign(:search, search) |> assign(:users, users)}
end
@impl true
def handle_info({:export_csv_requested, _params}, socket) do
filename = CSVExporter.generate_filename("users")
csv_content = CSVExporter.export_users_to_csv(socket.assigns.users)
{:noreply,
socket
|> put_flash(:info, gettext("Users exported successfully"))
|> push_event("download_csv", %{filename: filename, content: csv_content})}
end
@impl true
def handle_info({:table_sort_changed, sort_config}, socket) do
%{field: field, direction: direction} = sort_config
users = sort_users(socket.assigns.users, field, direction)
current_sort = %{field: field, order: direction}
{:noreply,
socket
|> assign(:users, users)
|> assign(:current_sort, current_sort)}
end
@impl true
def handle_info({:table_action, action, user, _user_id}, socket) do
case action do
:view ->
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}")}
:edit ->
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}/edit")}
:delete ->
{:ok, _} = Accounts.delete_user(user)
{:noreply,
socket
|> put_flash(:info, gettext("User deleted successfully"))
|> assign(:users, list_users())}
end
end
def list_users do
Admin.list_all_users()
end
defp search_users(search) when search == "", do: list_users()
defp search_users(search) do
Admin.list_all_users(%{"search" => search})
end
defp sort_users(users, field, order) do
Enum.sort_by(users, &Map.get(&1, field), order)
end
def sort_indicator(assigns) do
~H"""
<%= if @current_sort.field == @field do %>
<%= if @current_sort.order == :asc do %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<% end %>
<% else %>
<svg
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<% end %>
"""
end
end

View File

@@ -0,0 +1,348 @@
<%= case @live_action do %>
<% :index -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("Users")}</h1>
<div class="flex space-x-3">
<%!-- <.link navigate={~p"/admin/users/new"} class="btn btn-primary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
class="w-3 h-3"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 4v16m8-8H4"
/>
</svg>
{gettext("New user")}
</.link> --%>
</div>
</div>
</div>
<div class="mt-4">
<div class="py-4">
<!-- Search Bar -->
<div class="mb-6">
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
<label class="input">
<svg
class="h-[1em] opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search"
name="search"
value={@search}
placeholder={gettext("Search users...")}
/>
</label>
</form>
</div>
<!-- Users Table -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-0">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="email"
class="btn btn-ghost btn-sm"
>
{gettext("Email")} {sort_indicator(assigns |> Map.put(:field, :email))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="role_id"
class="btn btn-ghost btn-sm"
>
{gettext("Role")} {sort_indicator(assigns |> Map.put(:field, :role_id))}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="confirmed_at"
class="btn btn-ghost btn-sm"
>
{gettext("Status")} {sort_indicator(
assigns
|> Map.put(:field, :confirmed_at)
)}
</button>
</th>
<th class="text-left">
<button
phx-click="sort"
phx-value-field="inserted_at"
class="btn btn-ghost btn-sm"
>
{gettext("Created")} {sort_indicator(
assigns
|> Map.put(:field, :inserted_at)
)}
</button>
</th>
<th class="text-right">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody>
<%= if Enum.empty?(@users) do %>
<tr>
<td colspan="5" class="text-center">
{gettext("No users found")}
</td>
</tr>
<% else %>
<%= for user <- @users do %>
<tr id={"user-#{user.id}"}>
<td class="font-medium">
{user.email}
</td>
<td>
{if user.role, do: user.role.name, else: gettext("No role")}
</td>
<td>
<%= if user.confirmed_at do %>
<span class="badge badge-success">
{gettext("Confirmed")}
</span>
<% else %>
<span class="badge badge-warning">
{gettext("Unconfirmed")}
</span>
<% end %>
</td>
<td>
{Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/admin/users/#{user}"}
class="btn btn-link btn-sm"
title={gettext("View user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</.link>
<.link
navigate={~p"/admin/users/#{user}/edit"}
class="btn btn-link btn-sm text-primary"
title={gettext("Edit user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</.link>
<a
href="#"
phx-click="delete"
phx-value-id={user.id}
data-confirm={
gettext("Are you sure you want to delete this user?")
}
class="btn btn-link btn-sm text-error"
title={gettext("Delete user")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</a>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<% :show -> %>
<div>
<div>
<div class="flex justify-between items-center mb-24">
<h1 class="text-3xl font-bold">{gettext("User details")}</h1>
<div class="flex space-x-3">
<.link navigate={~p"/admin/users"} class="btn btn-outline btn-sm">
{gettext("Back to users")}
</.link>
<.link navigate={~p"/admin/users/#{@user}/edit"} class="btn btn-primary btn-sm">
{gettext("Edit")}
</.link>
</div>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{@user.email}</h2>
<p class="text-base-content/70">{gettext("User Account")}</p>
<div class="divider"></div>
<dl class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Email")}</dt>
<dd class="col-span-2">{@user.email}</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Role")}</dt>
<dd class="col-span-2">
{if @user.role, do: @user.role.name, else: gettext("No role")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Status")}</dt>
<dd class="col-span-2">
<%= if @user.confirmed_at do %>
<span class="badge badge-success">
{gettext("Confirmed")}
</span>
<% else %>
<span class="badge badge-warning">
{gettext("Unconfirmed")}
</span>
<% end %>
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Created At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Last Updated")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<%= if @user.confirmed_at do %>
<div class="grid grid-cols-3 gap-4">
<dt class="font-medium">{gettext("Confirmed At")}</dt>
<dd class="col-span-2">
{Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M")}
</dd>
</div>
<% end %>
</dl>
</div>
</div>
</div>
</div>
<% :new -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("New user")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.UserLive.FormComponent}
id="user-form"
title={gettext("New user")}
action={:new}
user={@user}
navigate={~p"/admin/users"}
/>
</div>
</div>
</div>
</div>
<% :edit -> %>
<div>
<div>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold">{gettext("Edit user")}</h1>
</div>
</div>
<div class="mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.live_component
module={ClaperWeb.AdminLive.UserLive.FormComponent}
id={"user-form-#{@user.id}"}
title={gettext("Edit user")}
action={:edit}
user={@user}
navigate={~p"/admin/users"}
/>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,196 @@
defmodule ClaperWeb.AdminLive.UserLive.FormComponent do
use ClaperWeb, :live_component
alias Claper.Accounts
@impl true
def render(assigns) do
~H"""
<div>
<.form for={@form} id="user-form" phx-target={@myself} phx-change="validate" phx-submit="save">
<div class="grid grid-cols-6 gap-6">
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="user-email"
form={@form}
field={:email}
type="email"
label={gettext("Email")}
placeholder={gettext("Enter user email")}
required={true}
width_class="sm:col-span-6"
description={gettext("User's email address (must be unique)")}
/>
<%= if @action == :new do %>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="user-password"
form={@form}
field={:password}
type="password"
label={gettext("Password")}
placeholder={gettext("Enter password")}
required={true}
width_class="sm:col-span-3"
description={gettext("Initial password for the user")}
/>
<% end %>
<.live_component
module={ClaperWeb.AdminLive.FormFieldComponent}
id="user-role-id"
form={@form}
field={:role_id}
type="select"
label={gettext("Role")}
select_options={@role_options}
required={true}
width_class="sm:col-span-3"
description={gettext("User's access level")}
/>
<div class="sm:col-span-6">
<div class="form-control">
<label class="label cursor-pointer justify-start">
<input
type="checkbox"
name="user[confirmed]"
value="true"
checked={@confirmed_checked}
class="checkbox checkbox-primary"
/>
<span class="label-text ml-2">{gettext("Account is confirmed and active")}</span>
</label>
</div>
</div>
</div>
<div class="pt-6">
<div class="flex justify-end gap-3">
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
{gettext("Cancel")}
</button>
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
{if @action == :new, do: gettext("Create User"), else: gettext("Update User")}
</button>
</div>
</div>
</.form>
</div>
"""
end
@impl true
def update(%{user: user} = assigns, socket) do
# For edit action, ensure we have the current role_id in the changeset
# attrs =
# case assigns.action do
# :edit -> %{"role_id" => user.role_id}
# :new -> %{}
# end
changeset = Accounts.change_user(user)
role_options =
Accounts.list_roles()
|> Enum.map(&{String.capitalize(&1.name), &1.id})
# Determine if confirmed checkbox should be checked
confirmed_checked =
case assigns.action do
:edit -> !is_nil(user.confirmed_at)
:new -> false
end
{:ok,
socket
|> assign(assigns)
|> assign(:role_options, role_options)
|> assign(:confirmed_checked, confirmed_checked)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
# Update the confirmed_checked state based on form params
confirmed_checked = Map.get(user_params, "confirmed") == "true"
# Convert confirmed checkbox to confirmed_at datetime
user_params = maybe_convert_confirmed_field(user_params)
changeset =
socket.assigns.user
|> Accounts.change_user(user_params)
|> Map.put(:action, :validate)
{:noreply,
socket
|> assign(:confirmed_checked, confirmed_checked)
|> assign_form(changeset)}
end
def handle_event("save", %{"user" => user_params}, socket) do
# Convert confirmed checkbox to confirmed_at datetime
user_params = maybe_convert_confirmed_field(user_params)
save_user(socket, socket.assigns.action, user_params)
end
def handle_event("cancel", _params, socket) do
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
end
defp save_user(socket, :edit, user_params) do
case Accounts.update_user(socket.assigns.user, user_params) do
{:ok, user} ->
notify_parent({:saved, user})
{:noreply,
socket
|> put_flash(:info, gettext("User updated successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_user(socket, :new, user_params) do
case Accounts.create_user(user_params) do
{:ok, user} ->
notify_parent({:saved, user})
{:noreply,
socket
|> put_flash(:info, gettext("User created successfully"))
|> push_navigate(to: socket.assigns.navigate)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: :user)
assign(socket, :form, form)
end
defp maybe_convert_confirmed_field(user_params) do
case Map.get(user_params, "confirmed") do
"true" ->
user_params
|> Map.delete("confirmed")
|> Map.put("confirmed_at", NaiveDateTime.utc_now())
"false" ->
user_params
|> Map.delete("confirmed")
|> Map.put("confirmed_at", nil)
_ ->
user_params
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@@ -12,8 +12,6 @@
form={f}
key={:title}
name={gettext("Title")}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
@@ -24,8 +22,6 @@
key={:provider}
name={gettext("Provider")}
array={@providers}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white h-full"}
required="true"
/>
<div class="flex-1">
@@ -38,24 +34,22 @@
do: gettext("Iframe code"),
else: gettext("Link to the content")
}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
</div>
</div>
<p class="text-gray-700 text-xl font-semibold mt-5"><%= gettext("Options") %></p>
<p class="text-gray-700 text-xl font-semibold mt-5">{gettext("Options")}</p>
<div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :attendee_visibility, class: "h-4 w-5") %>
<%= label(
{checkbox(f, :attendee_visibility, class: "h-4 w-5")}
{label(
f,
:attendee_visibility,
gettext("Attendees can view the web content on their device"),
class: "text-sm font-medium"
) %>
)}
</div>
</div>
@@ -63,15 +57,15 @@
<button
type="submit"
phx_disable_with="Loading..."
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= case @live_action do
{case @live_action do
:new -> gettext("Create")
:edit -> gettext("Save")
end %>
end}
</button>
<%= if @live_action == :edit do %>
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_target: @myself,
@@ -80,8 +74,8 @@
confirm: gettext("This will delete the web content, are you sure?")
],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
<% end %>
</div>
</.form>

View File

@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
<span class="font-bold"><%= gettext("See current web content") %></span>
<span class="font-bold">{gettext("See current web content")}</span>
</div>
</div>
</div>
@@ -52,8 +52,8 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
</svg>
</div>
<p class="text-xs text-gray-500 my-1"><%= gettext("Current web content") %></p>
<p class="text-white text-lg font-semibold mb-4"><%= @embed.title %></p>
<p class="text-xs text-gray-500 my-1">{gettext("Current web content")}</p>
<p class="text-white text-lg font-semibold mb-4">{@embed.title}</p>
</div>
<div class="flex flex-col space-y-3">
<.live_component

View File

@@ -39,7 +39,7 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
>
</iframe>
<% "custom" -> %>
<%= raw(@content) %>
{raw(@content)}
<% end %>
</div>
"""

View File

@@ -20,7 +20,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
class="text-lg font-medium text-primary-600 truncate"
href={~p"/e/#{@event.code}/manage"}
>
<%= @event.name %>
{@event.name}
</a>
<p
:if={@event.lti_resource}
@@ -42,22 +42,22 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<span>LTI</span>
</p>
</div>
<div class="ml-2 flex-shrink-0 flex">
<div class="ml-2 shrink-0 flex">
<%= if Event.started?(@event) && !Event.finished?(@event) do %>
<div class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-500 text-white items-center gap-x-1">
<span class="h-2 w-2 bg-white rounded-full animate__animated animate__flash animate__infinite animate__slow_slow">
</span>
<%= gettext("Live") %>
{gettext("Live")}
</div>
<% end %>
<%= if !Event.started?(@event) && !Event.finished?(@event) do %>
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
<%= gettext("Incoming") %>
{gettext("Incoming")}
</p>
<% end %>
<%= if Event.finished?(@event) do %>
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
<%= gettext("Finished") %>
{gettext("Finished")}
</p>
<% end %>
</div>
@@ -66,7 +66,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div class="text-sm font-medium uppercase text-gray-700 flex justify-center space-x-1 items-center">
<img src="/images/icons/hashtag.svg" class="h-5 w-5" />
<p>
<%= @event.code %>
{@event.code}
</p>
</div>
<div
@@ -83,11 +83,11 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
class="h-5 w-5"
/>
<p :if={!Event.finished?(@event) && !Event.started?(@event)}>
<%= gettext("Starting on") %>
{gettext("Starting on")}
<span x-text={"moment.utc('#{@event.started_at}').local().format('lll')"}></span>
</p>
<p :if={Event.finished?(@event)}>
<%= gettext("Finished on") %>
{gettext("Finished on")}
<span x-text={"moment.utc('#{@event.expired_at}').local().format('lll')"}></span>
</p>
</div>
@@ -106,9 +106,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
phx-click-away={JS.hide(to: "#dropdown-#{@event.uuid}")}
phx-click={JS.toggle(to: "#dropdown-#{@event.uuid}")}
phx-target={@myself}
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-primary-600 bg-primary-500"
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-primary-600 bg-primary-500"
>
<span class="mr-2"><%= gettext("Join") %></span>
<span class="mr-2">{gettext("Join")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -127,14 +127,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div
phx-hook="Dropdown"
id={"dropdown-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max z-30"
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max z-30"
>
<ul>
<li>
<a
data-phx-link="patch"
data-phx-link-state="push"
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/e/#{@event.code}/manage"}
>
<svg
@@ -149,14 +149,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Event manager") %></span>
<span>{gettext("Event manager")}</span>
</a>
</li>
<li>
<a
data-phx-link="patch"
data-phx-link-state="push"
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/e/#{@event.code}"}
>
<svg
@@ -172,7 +172,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
/>
<path d="M5.082 14.254a8.287 8.287 0 0 0-1.308 5.135 9.687 9.687 0 0 1-1.764-.44l-.115-.04a.563.563 0 0 1-.373-.487l-.01-.121a3.75 3.75 0 0 1 3.57-4.047ZM20.226 19.389a8.287 8.287 0 0 0-1.308-5.135 3.75 3.75 0 0 1 3.57 4.047l-.01.121a.563.563 0 0 1-.373.486l-.115.04c-.567.2-1.156.349-1.764.441Z" />
</svg>
<span><%= gettext("Attendees room") %></span>
<span>{gettext("Attendees room")}</span>
</a>
</li>
</ul>
@@ -186,7 +186,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
}
phx-value-id={@event.uuid}
phx-click="terminate"
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-red-500 hover:bg-red-600 transition"
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-red-500 hover:bg-red-600 transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -198,7 +198,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<span><%= gettext("End") %></span>
<span>{gettext("End")}</span>
</.link>
</div>
<div class="flex items-start gap-x-2 relative text-sm ">
@@ -207,9 +207,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
phx-target={@myself}
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
>
<span class="mr-2"><%= gettext("More options") %></span>
<span class="mr-2">{gettext("More options")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -228,12 +228,12 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
>
<ul>
<li>
<a
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/events/#{@event.uuid}/edit"}
data-phx-link="patch"
data-phx-link-state="push"
@@ -247,14 +247,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
</svg>
<span><%= gettext("Edit") %></span>
<span>{gettext("Edit")}</span>
</a>
</li>
<li>
<button
phx-value-id={@event.uuid}
phx-click="duplicate"
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -265,7 +265,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
</svg>
<span><%= gettext("Duplicate") %></span>
<span>{gettext("Duplicate")}</span>
</button>
</li>
</ul>
@@ -279,7 +279,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
class="mt-2 flex flex-col space-y-2 sm:space-y-0 justify-between sm:flex-row items-center"
>
<span class="text-sm text-supporting-red-500">
<%= gettext("Error when processing the file") %>
{gettext("Error when processing the file")}
</span>
<div class="relative text-sm">
<%= if not @is_leader do %>
@@ -287,9 +287,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
phx-target={@myself}
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
>
<span class="mr-2"><%= gettext("More options") %></span>
<span class="mr-2">{gettext("More options")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -308,12 +308,12 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
>
<ul>
<li>
<a
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
href={~p"/events/#{@event.uuid}/edit"}
data-phx-link="patch"
data-phx-link-state="push"
@@ -327,7 +327,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
</svg>
<span><%= gettext("Edit") %></span>
<span>{gettext("Edit")}</span>
</a>
</li>
</ul>
@@ -341,7 +341,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
class="flex space-x-1 items-center"
>
<img src="/images/loading.gif" class="h-8" />
<span class="text-sm text-gray-500"><%= gettext("Processing your file...") %></span>
<span class="text-sm text-gray-500">{gettext("Processing your file...")}</span>
</div>
<% end %>
@@ -356,7 +356,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
>
<a
href={~p"/events/#{@event.uuid}/stats"}
class="flex w-full lg:w-auto px-3 text-white py-2 justify-center rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-primary-600 bg-primary-500 space-x-2"
class="flex w-full lg:w-auto px-3 text-white py-2 justify-center rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-primary-600 bg-primary-500 space-x-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -367,7 +367,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg>
<span><%= gettext("View report") %></span>
<span>{gettext("View report")}</span>
</a>
</div>
<div class="relative text-sm">
@@ -376,9 +376,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
phx-target={@myself}
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
>
<span class="mr-2"><%= gettext("More options") %></span>
<span class="mr-2">{gettext("More options")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -397,14 +397,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div
phx-hook="Dropdown"
id={"dropdown-action-#{@event.uuid}"}
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
>
<ul>
<li>
<button
phx-value-id={@event.uuid}
phx-click="duplicate"
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -415,7 +415,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
</svg>
<span><%= gettext("Duplicate") %></span>
<span>{gettext("Duplicate")}</span>
</button>
</li>
<li>
@@ -427,7 +427,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
"This will delete all data related to your event, this cannot be undone. Confirm ?"
)
}
class="py-2 px-2 rounded text-red-500 hover:bg-gray-100 flex items-center gap-x-2 flex items-center gap-x-2 cursor-pointer"
class="py-2 px-2 rounded-sm text-red-500 hover:bg-gray-100 flex items-center gap-x-2 flex items-center gap-x-2 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -441,7 +441,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Delete") %></span>
<span>{gettext("Delete")}</span>
</.link>
</li>
</ul>

View File

@@ -33,13 +33,13 @@
/>
</svg>
<span><%= gettext("How it works ?") %></span>
<span>{gettext("How it works ?")}</span>
</button>
</div>
<div class="border-b border-gray-200 py-4 flex flex-col sm:flex-row sm:items-center justify-between">
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
<%= @page_title %>
{@page_title}
</h1>
</div>
<div class="flex mt-4 space-x-5 sm:mt-0">
@@ -48,30 +48,30 @@
type="submit"
form="event-form"
phx_disable_with="Loading..."
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= case @action do
{case @action do
:edit -> gettext("Save")
:new -> gettext("Create")
end %>
end}
</button>
<% else %>
<div class="opacity-25 cursor-default w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0">
<%= case @action do
<div class="opacity-25 cursor-default w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%]">
{case @action do
:edit -> gettext("Save")
:new -> gettext("Create")
end %>
end}
</div>
<% end %>
<%= if @action == :edit && !@event.expired_at do %>
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_value_id: @event.uuid,
data: [confirm: gettext("Are you sure?")],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
<% end %>
</div>
</div>
@@ -85,7 +85,7 @@
data-tg-title={"📄 #{gettext("Presentation file (optional)")}"}
>
<label class="block text-sm font-medium text-gray-700 mb-2">
<%= gettext("Select your presentation (optional)") %>
{gettext("Select your presentation (optional)")}
</label>
<div class="max-w-lg flex flex-col justify-center items-center px-6 pt-5 pb-6 border-2 bg-white shadow-base border-gray-300 border-dashed rounded-md">
<%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %>
@@ -112,22 +112,22 @@
phx-submit="save-file"
phx-target={@myself}
>
<span><%= gettext("Upload a file") %></span>
<span>{gettext("Upload a file")}</span>
<.live_file_input upload={@uploads.presentation_file} class="sr-only" />
</form>
</label>
<p class="pl-1"><%= gettext("or drag and drop") %></p>
<p class="pl-1">{gettext("or drag and drop")}</p>
</div>
<p class="text-xs text-gray-500">
<%= gettext("PDF, PPT, PPTX up to %{size} MB", size: @max_file_size) %>
{gettext("PDF, PPT, PPTX up to %{size} MB", size: @max_file_size)}
</p>
<%= for entry <- @uploads.presentation_file.entries do %>
<progress id="file" max="100" value={entry.progress}>
<%= entry.progress %>
{entry.progress}
</progress>
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
<%= error_to_string(err) %>
{error_to_string(err)}
</p>
<% end %>
<% end %>
@@ -149,9 +149,9 @@
d="M5 13l4 4L19 7"
/>
</svg>
<p class="text-gray-700"><%= gettext("Presentation uploaded") %></p>
<p class="text-gray-700">{gettext("Presentation uploaded")}</p>
</div>
<p class="text-xs text-gray-400"><%= entry.client_name %></p>
<p class="text-xs text-gray-400">{entry.client_name}</p>
<p>
<a
href="#"
@@ -160,7 +160,7 @@
phx-target={@myself}
class="text-red-500 text-sm"
>
<%= gettext("Remove") %>
{gettext("Remove")}
</a>
</p>
<% end %>
@@ -170,7 +170,7 @@
<% else %>
<div class="mt-12 mb-3">
<label class="block text-sm font-medium text-gray-700 mb-2">
<%= gettext("Select your presentation") %>
{gettext("Select your presentation")}
</label>
<div class="max-w-lg flex flex-col justify-center items-center px-6 pt-5 pb-6 border-2 bg-white shadow-base border-gray-300 border-dashed rounded-md">
<%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %>
@@ -189,7 +189,7 @@
d="M5 13l4 4L19 7"
/>
</svg>
<p class="text-gray-700"><%= gettext("Presentation attached") %></p>
<p class="text-gray-700">{gettext("Presentation attached")}</p>
</div>
<div class="flex flex-col space-y-3 items-center">
<label class="text-primary-500 text-sm">
@@ -199,24 +199,24 @@
phx-submit="save-file"
phx-target={@myself}
>
<span><%= gettext("Change file") %></span>
<span>{gettext("Change file")}</span>
<.live_file_input upload={@uploads.presentation_file} class="sr-only" />
</form>
</label>
<%= for entry <- @uploads.presentation_file.entries do %>
<progress id="file" max="100" value={entry.progress}>
<%= entry.progress %>
{entry.progress}
</progress>
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
<%= error_to_string(err) %>
{error_to_string(err)}
</p>
<% end %>
<% end %>
<p class="text-supporting-red-500 text-sm italic text-center hidden">
<%= gettext(
{gettext(
"Changing your file will remove all interaction elements like polls associated."
) %>
)}
</p>
</div>
<% else %>
@@ -235,19 +235,19 @@
d="M5 13l4 4L19 7"
/>
</svg>
<p class="text-gray-700"><%= gettext("Presentation replaced") %></p>
<p class="text-gray-700">{gettext("Presentation replaced")}</p>
</div>
<%= for entry <- @uploads.presentation_file.entries do %>
<p class="text-xs text-gray-400"><%= entry.client_name %></p>
<p class="text-xs text-gray-400">{entry.client_name}</p>
<div :if={entry.progress < 100}>
<progress id="file" max="100" value={entry.progress}>
<%= entry.progress %>
{entry.progress}
</progress>
</div>
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
<%= error_to_string(err) %>
{error_to_string(err)}
</p>
<% end %>
<p>
@@ -258,7 +258,7 @@
phx-target={@myself}
class="text-red-500 text-sm"
>
<%= gettext("Remove") %>
{gettext("Remove")}
</a>
</p>
<% end %>
@@ -267,7 +267,7 @@
<%= for entry <- @uploads.presentation_file.entries do %>
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
<%= error_to_string(err) %>
{error_to_string(err)}
</p>
<% end %>
<% end %>
@@ -339,7 +339,7 @@
data-tg-order="4"
>
<span class="text-lg block font-medium text-gray-700">
<%= gettext("Facilitators can present and manage interactions") %>
{gettext("Facilitators can present and manage interactions")}
</span>
<button
type="button"
@@ -360,7 +360,7 @@
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Add facilitator") %></span>
<span>{gettext("Add facilitator")}</span>
</button>
</div>
@@ -381,15 +381,15 @@
key={:email}
name=""
/>
<%= hidden_input(l, :user_email, value: @current_user.email) %>
{hidden_input(l, :user_email, value: @current_user.email)}
</div>
<label
phx-click={JS.hide(to: "##{l.data.temp_id || l.id}")}
class="cursor-pointer md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-7 px-3 text-sm max-h-0"
>
<span><%= gettext("Remove") %></span>
<%= checkbox(l, :delete, class: "hidden") %>
<span>{gettext("Remove")}</span>
{checkbox(l, :delete, class: "hidden")}
</label>
<% else %>
<div class="relative">
@@ -401,9 +401,9 @@
/>
</div>
<%= hidden_input(l, :temp_id) %>
<%= hidden_input(l, :event_id, value: @event.id) %>
<%= hidden_input(l, :user_email, value: @current_user.email) %>
{hidden_input(l, :temp_id)}
{hidden_input(l, :event_id, value: @event.id)}
{hidden_input(l, :user_email, value: @current_user.email)}
<button
type="button"
@@ -412,7 +412,7 @@
phx-target={@myself}
class="md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-5 px-3 text-sm max-h-0"
>
<span><%= gettext("Remove") %></span>
<span>{gettext("Remove")}</span>
</button>
<% end %>
</div>

View File

@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
<path d="M17 12h.01"></path>
<path d="M13 12h.01"></path>
</svg>
<span class="font-bold"><%= gettext("See current form") %></span>
<span class="font-bold">{gettext("See current form")}</span>
</div>
</div>
</div>
@@ -48,8 +48,8 @@ defmodule ClaperWeb.EventLive.FormComponent do
</svg>
</div>
<p class="text-xs text-gray-500 my-1"><%= gettext("Current form") %></p>
<p class="text-white text-lg font-semibold mb-4"><%= @form.title %></p>
<p class="text-xs text-gray-500 my-1">{gettext("Current form")}</p>
<p class="text-white text-lg font-semibold mb-4">{@form.title}</p>
</div>
<%= form_for :form_submit, "#", [id: @id, phx_change: "validate", phx_target: @myself, phx_submit: "submit"], fn f -> %>
<div class="flex flex-col space-y-3">
@@ -63,7 +63,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
fieldClass="bg-gray-700 text-white"
key={String.to_atom(field.name)}
name={field.name}
required="true"
required={field.required}
value={
if is_nil(assigns.current_form_submit),
do: ~c"",
@@ -77,7 +77,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
fieldClass="bg-gray-700 text-white"
key={String.to_atom(field.name)}
name={field.name}
required="true"
required={field.required}
value={
if is_nil(assigns.current_form_submit),
do: ~c"",
@@ -94,7 +94,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
type="submit"
class="px-3 py-2 text-white font-semibold bg-primary-500 hover:bg-primary-600 rounded-md my-5"
>
<%= if is_nil(assigns.current_form_submit), do: gettext("Submit"), else: gettext("Edit") %>
{if is_nil(assigns.current_form_submit), do: gettext("Submit"), else: gettext("Edit")}
</button>
<%= unless is_nil(assigns.current_form_submit) do %>
@@ -111,7 +111,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M9 12l2 2l4 -4" />
</svg>
<span><%= gettext("Saved") %></span>
<span>{gettext("Saved")}</span>
</div>
<% end %>
</div>

View File

@@ -1,7 +1,7 @@
defmodule ClaperWeb.EventLive.Index do
use ClaperWeb, :live_view
alias Claper.Events
alias Claper.{Events, Presentations}
alias Claper.Events.Event
on_mount(ClaperWeb.UserLiveAuth)
@@ -22,7 +22,7 @@ defmodule ClaperWeb.EventLive.Index do
})
if connected?(socket) do
Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}")
Events.subscribe_user_events(socket.assigns.current_user.id)
end
expired_events_count = Events.count_expired_events(socket.assigns.current_user.id)
@@ -50,11 +50,22 @@ defmodule ClaperWeb.EventLive.Index do
end
@impl true
def handle_info({:presentation_file_process_done, presentation}, socket) do
event = Claper.Events.get_event!(presentation.event.uuid, [:presentation_file])
def handle_info({type, %Events.Event{}}, socket)
when type in [:created, :updated, :deleted] do
{:noreply, refresh_events(socket)}
end
{:noreply,
socket |> assign(:events, [event | socket.assigns.events]) |> put_flash(:info, nil)}
@impl true
def handle_info({type, %Presentations.PresentationFile{}}, socket)
when type in [:presentation_file_process_done] do
{:noreply, refresh_events(socket)}
end
@impl true
def handle_info(message, socket) do
IO.puts("Received unknown message `#{inspect(message)}` in #{__MODULE__} #{inspect(self())}")
{:noreply, socket}
end
@impl true
@@ -245,4 +256,16 @@ defmodule ClaperWeb.EventLive.Index do
if(socket.assigns.page == 1, do: events, else: socket.assigns.events ++ events)
)
end
defp refresh_events(socket) do
expired_events_count = Events.count_expired_events(socket.assigns.current_user.id)
invited_events_count = Events.count_managed_events_by(socket.assigns.current_user.email)
socket
|> assign(:has_expired_events, expired_events_count > 0)
|> assign(:has_invited_events, invited_events_count > 0)
|> assign(:events, [])
|> assign(:page, 1)
|> load_events()
end
end

View File

@@ -25,7 +25,7 @@
/>
</svg>
<span><%= gettext("How it works ?") %></span>
<span>{gettext("How it works ?")}</span>
</button>
</div>
<%= if @live_action in [:new, :edit] do %>
@@ -48,11 +48,11 @@
>
<div
phx-click="toggle-quick-create"
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
aria-hidden="true"
>
</div>
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
<button phx-click="toggle-quick-create" class="absolute right-0 top-0">
<svg
class="text-gray-500 h-9 transform rotate-45"
@@ -80,17 +80,18 @@
<ClaperWeb.Component.Input.text
form={f}
key={:name}
name=""
name={gettext("Name of your event")}
readonly={false}
minlength="5"
maxlength="50"
class="h-12"
placeholder={gettext("Name of your event")}
/>
<button
type="submit"
phx_disable_with="Loading..."
class="mt-5 w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="mt-5 w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0px_0px] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Create") %>
{gettext("Create")}
</button>
</.form>
</div>
@@ -108,7 +109,7 @@
>
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
<%= gettext("My events") %>
{gettext("My events")}
</h1>
</div>
<div class="flex flex-col w-full items-center sm:w-auto sm:flex-row mt-0">
@@ -138,7 +139,7 @@
/>
</svg>
<span>
<%= gettext("Quick event") %>
{gettext("Quick event")}
</span>
</.link>
<.link
@@ -147,7 +148,7 @@
data-tg-tour={gettext("Welcome to Claper! You can create a new event here.")}
data-tg-title={"#{gettext("Your first steps with Claper")} 👋"}
href={~p"/events/new"}
class="relative w-full sm:w-auto inline-flex justify-center items-center px-5 py-2 text-lg font-medium rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="relative w-full sm:w-auto inline-flex justify-center items-center px-5 py-2 text-lg font-medium rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<svg
class="-ml-1 mr-1 h-5 w-5"
@@ -163,7 +164,7 @@
/>
</svg>
<span>
<%= gettext("Create event") %>
{gettext("Create event")}
</span>
</.link>
</div>
@@ -175,7 +176,7 @@
phx-value-tab="not_expired"
class={"#{if @active_tab == "not_expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md"}
>
<%= gettext("Active") %>
{gettext("Active")}
</button>
<button
phx-click="change-tab"
@@ -183,7 +184,7 @@
disabled={not @has_expired_events}
class={"#{if @active_tab == "expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_expired_events, do: "opacity-50"}"}
>
<%= gettext("Finished") %>
{gettext("Finished")}
</button>
<button
phx-click="change-tab"
@@ -191,7 +192,7 @@
disabled={not @has_invited_events}
class={"#{if @active_tab == "invited", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_invited_events, do: "opacity-50"}"}
>
<%= gettext("Shared with you") %>
{gettext("Shared with you")}
</button>
</nav>
@@ -212,16 +213,16 @@
<div class="flex justify-center my-4">
<button
phx-click="load-more"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<%= gettext("Load more") %>
{gettext("Load more")}
</button>
</div>
<% end %>
<%= if Enum.count(@events) == 0 do %>
<div class="w-full text-2xl text-black opacity-25 text-center">
<img src="/images/icons/arrow.svg" class="h-20 float-right mr-16 -mt-5" />
<p class="pt-12 clear-both"><%= gettext("Create your first event") %></p>
<p class="pt-12 clear-both">{gettext("Create your first event")}</p>
</div>
<% end %>
</div>

View File

@@ -21,21 +21,21 @@
@click.away="open = false"
>
<a href="https://get.claper.co/" class="text-sm font-semibold text-black">
<%= gettext("About") %>
{gettext("About")}
</a>
<%= if @current_user do %>
<.link
href={~p"/events"}
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Dashboard") %>
{gettext("Dashboard")}
</.link>
<% else %>
<.link
href={~p"/users/log_in"}
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Login") %>
{gettext("Login")}
</.link>
<% end %>
</div>
@@ -44,21 +44,21 @@
</button>
<div class="hidden md:block">
<a href="https://get.claper.co/" class="text-sm text-white font-semibold mr-3">
<%= gettext("About") %>
{gettext("About")}
</a>
<%= if @current_user do %>
<.link
href={~p"/events"}
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Dashboard") %>
{gettext("Dashboard")}
</.link>
<% else %>
<.link
href={~p"/users/log_in"}
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Login") %>
{gettext("Login")}
</.link>
<% end %>
</div>
@@ -71,13 +71,13 @@
<%= form_for :event, ~p"/join", ["phx-submit": "join", "phx-hook": "JoinEvent", id: "form"], fn f -> %>
<div class="relative">
<%= text_input(f, :code,
{text_input(f, :code,
required: true,
autofocus: true,
id: "input",
class:
"transition-all bg-transparent tracking-widest w-full uppercase text-white text-2xl px-3 border-b border-gray-200 focus:border-b-2 pt-5 pl-12 pb-3 outline-none"
) %>
"transition-all bg-transparent tracking-widest w-full uppercase text-white text-2xl px-3 border-b border-gray-200 focus:border-b-2 pt-5 pl-12 pb-3 outline-hidden"
)}
<img
class="icon absolute top-5 left-2 transition-all duration-100"
src="/images/icons/hashtag-white.svg"
@@ -89,19 +89,19 @@
<button
type="submit"
id="submit"
class="w-full flex justify-center text-white p-4 rounded-full tracking-wide font-bold outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="w-full flex justify-center text-white p-4 rounded-full tracking-wide font-bold outline-hidden focus:shadow-outline shadow-lg bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= gettext("Join") %>
{gettext("Join")}
</button>
<img src="/images/loading.gif" id="loading" class="hidden h-12 mx-auto" />
</div>
<%= if @last_event do %>
<.link href={~p"/e/#{@last_event.code}"}>
<div class="rounded-md bg-gray-600 bg-opacity-50 p-4 mt-8">
<div class="rounded-md bg-gray-600/50 p-4 mt-8">
<div class="flex justify-center items-center">
<p class="text-sm text-white">
<%= gettext("Return to your last event") %> (<%= @last_event.name %>)
{gettext("Return to your last event")} ({@last_event.name})
</p>
<p class="text-base ml-3 mt-1">
<a href="#" class="whitespace-nowrap font-medium text-white">

View File

@@ -1,12 +1,8 @@
defmodule ClaperWeb.EventLive.Manage do
use ClaperWeb, :live_view
alias Claper.{Embeds, Forms, Polls, Presentations, Quizzes}
alias ClaperWeb.Presence
alias Claper.Polls
alias Claper.Forms
alias Claper.Embeds
# Add this line
alias Claper.Quizzes
@impl true
def mount(%{"code" => code}, session, socket) do
@@ -39,6 +35,7 @@ defmodule ClaperWeb.EventLive.Manage do
socket =
socket
|> assign(:interaction_modal, false)
|> assign(:settings_modal, false)
|> assign(:attendees_nb, 1)
|> assign(:event, event)
@@ -74,7 +71,7 @@ defmodule ClaperWeb.EventLive.Manage do
end
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
end
defp leader?(_socket, _event), do: false
@@ -585,6 +582,23 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket |> assign(:state, new_state)}
end
@impl true
def handle_event(
"checked",
%{"key" => "show_attendee_count", "value" => value},
%{assigns: %{event: _event, state: state}} = socket
) do
{:ok, new_state} =
Claper.Presentations.update_presentation_state(
state,
%{
:show_attendee_count => value
}
)
{:noreply, socket |> assign(:state, new_state)}
end
@impl true
def handle_event(
"checked",
@@ -762,18 +776,26 @@ defmodule ClaperWeb.EventLive.Manage do
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
def handle_event(
"toggle-interaction-modal",
_params,
%{assigns: %{interaction_modal: _interaction_modal = true}} = socket
) do
{:noreply, socket |> push_navigate(to: ~p"/e/#{socket.assigns.event.code}/manage")}
end
def toggle_add_modal(js \\ %JS{}) do
js
|> JS.toggle(
to: "#add-modal",
out: "animate__animated animate__fadeOut",
in: "animate__animated animate__fadeIn"
)
|> JS.push("maybe-redirect", target: "#add-modal")
@impl true
def handle_event(
"toggle-interaction-modal",
_params,
%{assigns: %{interaction_modal: _interaction_modal}} = socket
) do
{:noreply, socket |> assign(:interaction_modal, true)}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
def toggle_settings_modal(js \\ %JS{}) do
@@ -802,6 +824,7 @@ defmodule ClaperWeb.EventLive.Manage do
socket
|> assign(:create, "poll")
|> assign(:interaction_modal, true)
|> assign(:create_action, :edit)
|> assign(:poll, poll)
end
@@ -834,6 +857,7 @@ defmodule ClaperWeb.EventLive.Manage do
socket
|> assign(:create, "form")
|> assign(:interaction_modal, true)
|> assign(:create_action, :edit)
|> assign(:form, form)
end
@@ -843,6 +867,7 @@ defmodule ClaperWeb.EventLive.Manage do
socket
|> assign(:create, "embed")
|> assign(:interaction_modal, true)
|> assign(:create_action, :edit)
|> assign(:embed, embed)
end
@@ -873,6 +898,7 @@ defmodule ClaperWeb.EventLive.Manage do
socket
|> assign(:create, "quiz")
|> assign(:interaction_modal, true)
|> assign(:create_action, :edit)
|> assign(:quiz, quiz)
end

View File

@@ -32,7 +32,7 @@
/>
</svg>
<span><%= gettext("How it works ?") %></span>
<span>{gettext("How it works ?")}</span>
</button>
</div>
<div
@@ -67,11 +67,11 @@
>
<div
phx-click={toggle_settings_modal()}
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
aria-hidden="true"
>
</div>
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
<button phx-click={toggle_settings_modal()} class="absolute right-0 top-0">
<svg
class="text-gray-500 h-9 transform rotate-45"
@@ -119,8 +119,8 @@
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" /><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" /><path d="M3 3l18 18" />
</svg>
<span :if={!@preview}><%= gettext("Open preview") %></span>
<span :if={@preview}><%= gettext("Close preview") %></span>
<span :if={!@preview}>{gettext("Open preview")}</span>
<span :if={@preview}>{gettext("Close preview")}</span>
</button>
<div id="settings-modal-content" class="bg-white p-4">
<.live_component
@@ -134,22 +134,21 @@
</div>
</div>
</div>
<div
id="add-modal"
class={"#{if !@create, do: "hidden" } fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24
transform transition-all duration-150"}
:if={@interaction_modal}
id="interaction-modal"
class="fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24 transform transition-all duration-150"
role="dialog"
aria-modal="true"
>
<div
phx-click={toggle_add_modal()}
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
phx-click="toggle-interaction-modal"
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
aria-hidden="true"
>
</div>
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<button phx-click={toggle_add_modal()} class="absolute right-0 top-0">
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
<button phx-click="toggle-interaction-modal" class="absolute right-0 top-0">
<svg
class="text-gray-500 h-9 transform rotate-45"
xmlns="http://www.w3.org/2000/svg"
@@ -174,7 +173,7 @@
href={~p"/e/#{@event.code}/manage/add/poll"}
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
>
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7"
@@ -192,10 +191,10 @@
</div>
<div class="ml-4 flex-auto text-left">
<p class="font-medium text-gray-700">
<%= gettext("Poll") %>
{gettext("Poll")}
</p>
<p class="text-gray-500">
<%= gettext("Add poll to know opinion of your public.") %>
{gettext("Add poll to know opinion of your public.")}
</p>
</div>
</a>
@@ -207,7 +206,7 @@
href={~p"/e/#{@event.code}/manage/add/form"}
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
>
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7"
@@ -225,10 +224,10 @@
</div>
<div class="ml-4 flex-auto text-left">
<p class="font-medium text-gray-700">
<%= gettext("Form") %>
{gettext("Form")}
</p>
<p class="text-gray-500">
<%= gettext("Add form to collect data from your public.") %>
{gettext("Add form to collect data from your public.")}
</p>
</div>
</a>
@@ -240,7 +239,7 @@
href={~p"/e/#{@event.code}/manage/add/embed"}
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
>
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -257,9 +256,9 @@
</svg>
</div>
<div class="ml-4 flex-auto text-left">
<p class="font-medium text-gray-700"><%= gettext("Web content") %></p>
<p class="font-medium text-gray-700">{gettext("Web content")}</p>
<p class="text-gray-500">
<%= gettext("Add a Youtube video or any web content.") %>
{gettext("Add a Youtube video or any web content.")}
</p>
</div>
</a>
@@ -271,7 +270,7 @@
href={~p"/e/#{@event.code}/manage/add/quiz"}
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
>
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -289,9 +288,9 @@
</svg>
</div>
<div class="ml-4 flex-auto text-left">
<p class="font-medium text-gray-700"><%= gettext("Quiz") %></p>
<p class="font-medium text-gray-700">{gettext("Quiz")}</p>
<p class="text-gray-500">
<%= gettext("Add a quiz to test knowledge.") %>
{gettext("Add a quiz to test knowledge.")}
</p>
</div>
</a>
@@ -301,10 +300,10 @@
<%= if @create=="poll" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= case @create_action do
{case @create_action do
:new -> gettext("New poll")
:edit -> gettext("Edit poll")
end %>
end}
</p>
<.live_component
module={ClaperWeb.PollLive.FormComponent}
@@ -322,10 +321,10 @@
<%= if @create=="form" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= case @create_action do
{case @create_action do
:new -> gettext("New form")
:edit -> gettext("Edit form")
end %>
end}
</p>
<.live_component
module={ClaperWeb.FormLive.FormComponent}
@@ -343,10 +342,10 @@
<%= if @create == "embed" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= case @create_action do
{case @create_action do
:new -> gettext("New web content")
:edit -> gettext("Edit web content")
end %>
end}
</p>
<.live_component
module={ClaperWeb.EmbedLive.FormComponent}
@@ -364,10 +363,10 @@
<%= if @create=="quiz" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= case @create_action do
{case @create_action do
:new -> gettext("New quiz")
:edit -> gettext("Edit quiz")
end %>
end}
</p>
<.live_component
module={ClaperWeb.QuizLive.QuizComponent}
@@ -385,7 +384,7 @@
<%= if @create == "import" do %>
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
<p class="text-xl font-bold">
<%= gettext("Select presentation") %>
{gettext("Select presentation")}
</p>
<ul>
<%= for event <- @events do %>
@@ -410,7 +409,7 @@
/>
</svg>
<span>
<%= event.name %>
{event.name}
</span>
</button>
</li>
@@ -453,13 +452,13 @@
</a>
<div class="flex items-center justify-start gap-x-2">
<h2 class="text-xl font-bold leading-7 text-gray-900 md:text-2xl truncate w-24 md:w-auto">
<%= @event.name %>
{@event.name}
</h2>
<div class="flex gap-x-3 items-center">
<div class="flex items-center text-sm text-gray-500 gap-x-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 h-5 w-5 text-gray-400"
class="shrink-0 h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -471,12 +470,12 @@
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
</svg>
<span class="uppercase"><%= @event.code %></span>
<span class="uppercase">{@event.code}</span>
</div>
<div class="flex items-center text-sm text-gray-500 gap-x-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 h-5 w-5 text-gray-400"
class="shrink-0 h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -487,14 +486,14 @@
/>
</svg>
<span id="attendees-count" phx-update="ignore" phx-hook="UpdateAttendees">
<%= @attendees_nb %>
{@attendees_nb}
</span>
<span>
<%= link(gettext("Join"),
{link(gettext("Join"),
to: ~p"/e/#{@event.code}",
class: "text-xs text-primary-600 font-semibold text-sm ",
target: "_blank"
) %>
)}
</span>
</div>
</div>
@@ -535,8 +534,8 @@
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" /><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" /><path d="M3 3l18 18" />
</svg>
<span :if={!@preview}><%= gettext("Open preview") %></span>
<span :if={@preview}><%= gettext("Close preview") %></span>
<span :if={!@preview}>{gettext("Open preview")}</span>
<span :if={@preview}>{gettext("Close preview")}</span>
</button>
<button
phx-hook="OpenPresenter"
@@ -547,7 +546,7 @@
data-tg-order="4"
data-tg-tour={"<p>#{gettext("Click here to open the presentation window. Press <strong>F</strong> in the presentation window to enable fullscreen.")}</p>"}
data-tg-group="manage"
class="hidden lg:inline-flex items-center py-1 px-5 rounded-r-sm text-base font-medium text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="hidden lg:inline-flex items-center py-1 px-5 rounded-r-sm text-base font-medium text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -563,7 +562,7 @@
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
<%= gettext("Open presentation") %>
{gettext("Open presentation")}
</button>
</div>
<div class="flex items-center gap-x-2 lg:hidden">
@@ -670,34 +669,26 @@
id="slides-layout"
class="flex overflow-x-auto w-full md:h-full"
>
<%= for index <- 0..max(0, @event.presentation_file.length-1) do %>
<button
id={"slide-preview-#{index}"}
phx-click="current-page"
phx-value-page={index}
class="h-full w-full contents"
>
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
<img
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
<button
:for={
{src, index} <-
Presentations.get_slide_urls(@event.presentation_file) |> Enum.with_index(0)
}
id={"slide-preview-#{index}"}
phx-click="current-page"
phx-value-page={index}
class="h-full w-full contents"
>
<img
src={src}
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
transition-all object-contain"}
src={"/uploads/#{@event.presentation_file.hash}/#{index+1}.jpg"}
/>
<% else %>
<img
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
transition-all object-contain"}
src={"https://#{Application.get_env(:claper, :presentations) |>
Keyword.get(:aws_bucket)}.s3.#{Application.get_env(:ex_aws,
:region)}.amazonaws.com/presentations/#{@event.presentation_file.hash}/#{index+1}.jpg"}
/>
<% end %>
</button>
<% end %>
/>
</button>
</div>
<div
:if={@event.presentation_file.length > 0}
class="hidden md:block gutter col-span-full cursor-row-resize z-20 row-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3"
class="hidden md:block gutter col-span-full cursor-row-resize z-20 row-2 bg-gray-50 text-center text-gray-300 text-sm leading-3"
>
•••
</div>
@@ -737,16 +728,16 @@
<p class="text-lg">
<span :if={@event.presentation_file.length > 0}>
<%= gettext("This slide does not have any interactions.") %>
{gettext("This slide does not have any interactions.")}
</span>
<span :if={@event.presentation_file.length == 0}>
<%= gettext("Create your first interaction.") %>
{gettext("Create your first interaction.")}
</span>
</p>
<button
class="flex items-center justify-center gap-x-2 px-3 py-2 text-white bg-primary-500 hover:bg-primary-600 rounded-md"
phx-click={toggle_add_modal()}
phx-click="toggle-interaction-modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -756,10 +747,13 @@
>
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
<span><%= gettext("Add interaction") %></span>
<span>{gettext("Add interaction")}</span>
</button>
</div>
<div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4 p-4 overflow-y-auto">
<div
class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4 p-4 overflow-y-auto"
style="max-height: calc(100vh - 80px);"
>
<%= for interaction <- @interactions do %>
<div class="bg-white rounded-lg p-3 shadow-base transition-all flex flex-col justify-between relative">
<div>
@@ -767,7 +761,7 @@
<% %Claper.Polls.Poll{} -> %>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center w-full">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -783,11 +777,11 @@
/>
</svg>
</div>
<span class="font-semibold"><%= gettext("Poll") %></span>
<span class="font-semibold">{gettext("Poll")}</span>
</div>
<a
class="p-2 rounded text-xs font-medium text-center text-primary-500"
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
data-phx-link="patch"
data-phx-link-state="push"
href={~p"/e/#{@event.code}/manage/edit/poll/#{interaction.id}"}
@@ -811,7 +805,7 @@
<% %Claper.Forms.Form{} -> %>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center w-full">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -827,13 +821,11 @@
/>
</svg>
</div>
<span class="font-semibold"><%= gettext("Form") %></span>
<span class="font-semibold">{gettext("Form")}</span>
</div>
<a
class="p-2 rounded text-xs font-medium text-center text-primary-500"
data-phx-link="patch"
data-phx-link-state="push"
href={~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}"}
<.link
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
patch={~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -849,12 +841,12 @@
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</a>
</.link>
</div>
<% %Claper.Embeds.Embed{} -> %>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center w-full">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -871,11 +863,11 @@
</svg>
</div>
<span class="font-semibold">
<%= gettext("Web content") %>
{gettext("Web content")}
</span>
</div>
<a
class="p-2 rounded text-xs font-medium text-center text-primary-500"
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
data-phx-link="patch"
data-phx-link-state="push"
href={~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}"}
@@ -899,7 +891,7 @@
<% %Claper.Quizzes.Quiz{} -> %>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center w-full">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -917,7 +909,7 @@
</svg>
</div>
<span class="font-semibold">
<%= gettext("Quiz") %>
{gettext("Quiz")}
</span>
<p
:if={interaction.lti_resource_id}
@@ -940,7 +932,7 @@
</p>
</div>
<a
class="p-2 rounded text-xs font-medium text-center text-primary-500"
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
data-phx-link="patch"
data-phx-link-state="push"
href={~p"/e/#{@event.code}/manage/edit/quiz/#{interaction.id}"}
@@ -1079,7 +1071,7 @@
<% end %>
<% _ -> %>
<% end %>
<span><%= interaction.title %></span>
<span>{interaction.title}</span>
</div>
</div>
<div class="w-full">
@@ -1096,9 +1088,9 @@
end
}
phx-value-id={interaction.id}
class="bg-supporting-red-100 text-supporting-red-800 px-2 py-2 rounded text-sm font-medium w-full"
class="bg-supporting-red-100 text-supporting-red-800 px-2 py-2 rounded-sm text-sm font-medium w-full cursor-pointer"
>
<%= gettext("Disable") %>
{gettext("Disable")}
</button>
</div>
<% else %>
@@ -1113,9 +1105,9 @@
end
}
phx-value-id={interaction.id}
class="bg-primary-100 text-primary-800 px-2 py-2 rounded text-sm font-medium w-full"
class="bg-primary-100 text-primary-800 px-2 py-2 rounded-sm text-sm font-medium w-full cursor-pointer"
>
<%= gettext("Enable") %>
{gettext("Enable")}
</button>
<% end %>
</div>
@@ -1123,13 +1115,13 @@
<% end %>
<button
:if={length(@interactions) > 0}
phx-click={toggle_add_modal()}
phx-click="toggle-interaction-modal"
class="
bg-white @container rounded-lg p-3 shadow-base transition-all flex flex-col justify-center items-center transform hover:scale-105"
>
<img src="/images/interaction-icons.png" class="w-2/3 @sm:w-1/3" />
<span class="font-semibold text-secondary-800">
<%= gettext("Add interaction") %>
{gettext("Add interaction")}
</span>
</button>
</div>
@@ -1137,7 +1129,7 @@
</div>
<div
class="hidden md:block gutter-1 row-span-full cursor-col-resize col-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3"
class="hidden md:block gutter-1 row-span-full cursor-col-resize col-2 bg-gray-50 text-center text-gray-300 text-sm leading-3"
style="writing-mode: vertical-rl"
>
•••
@@ -1164,35 +1156,35 @@
>
<li class={"rounded-md #{if @list_tab==:posts, do: "bg-secondary-600 text-white" ,
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
<%= link(gettext("Messages") <> " (#{@post_count})",
{link(gettext("Messages") <> " (#{@post_count})",
to: "#",
phx_click: "list-tab",
phx_value_tab: :posts
) %>
)}
</li>
<li class={"rounded-md #{if @list_tab==:questions, do: "bg-secondary-600 text-white" ,
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
<%= link(gettext("Questions") <> " (#{@question_count})",
{link(gettext("Questions") <> " (#{@question_count})",
to: "#",
phx_click: "list-tab",
phx_value_tab: :questions
) %>
)}
</li>
<li class={"rounded-md #{if @list_tab==:pinned_posts, do: "bg-secondary-600 text-white" ,
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
<%= link(gettext("Pinned messages") <> " (#{@pinned_post_count})",
{link(gettext("Pinned messages") <> " (#{@pinned_post_count})",
to: "#",
phx_click: "list-tab",
phx_value_tab: :pinned_posts
) %>
)}
</li>
<li class={"rounded-md #{if @list_tab==:forms, do: "bg-secondary-600 text-white" ,
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
<%= link(gettext("Form submissions") <> " (#{@form_submit_count})",
{link(gettext("Form submissions") <> " (#{@form_submit_count})",
to: "#",
phx_click: "list-tab",
phx_value_tab: :forms
) %>
)}
</li>
</ul>
@@ -1218,7 +1210,7 @@
</svg>
<p class="text-lg">
<%= gettext("Messages from attendees will appear here.") %>
{gettext("Messages from attendees will appear here.")}
</p>
</div>
<div
@@ -1259,7 +1251,7 @@
</svg>
<p class="text-lg">
<%= gettext("Questions will appear here.") %>
{gettext("Questions will appear here.")}
</p>
</div>
@@ -1280,7 +1272,7 @@
</svg>
<p class="flex items-center gap-x-1">
<%= gettext("Sort by popularity") %>
{gettext("Sort by popularity")}
</p>
</button>
<button
@@ -1301,7 +1293,7 @@
/>
</svg>
<span><%= gettext("Sort by date") %></span>
<span>{gettext("Sort by date")}</span>
</button>
</div>
<div
@@ -1341,7 +1333,7 @@
</svg>
<p class="text-lg">
<%= gettext("Pinned messages will appear here.") %>
{gettext("Pinned messages will appear here.")}
</p>
</div>
@@ -1387,7 +1379,7 @@
</svg>
<p class="text-lg">
<%= gettext("Form submissions from attendees will appear here.") %>
{gettext("Form submissions from attendees will appear here.")}
</p>
</div>
<% end %>
@@ -1402,20 +1394,20 @@
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-2">
<div class="float-right mr-1">
<span class="text-red-500">
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete-form-submit",
phx_value_id: submission.id,
phx_value_event_id: @event.uuid,
data: [confirm: gettext("This cannot be undone, confirm ?")]
) %>
)}
</span>
</div>
<p>
<span class="font-semibold text-lg">
<%= gettext("Form") %>
</span>: <%= submission.form.title %>
</span>: {submission.form.title}
</p>
<div class="flex space-x-3 items-center">
@@ -1435,9 +1427,9 @@
<%= for res <- submission.response do %>
<p>
<strong>
<%= elem(res, 0) %>:
{elem(res, 0)}:
</strong>
<%= elem(res, 1) %>
{elem(res, 1)}
</p>
<% end %>
</div>
@@ -1448,28 +1440,29 @@
<% end %>
</div>
<div class="hidden lg:block gutter-2 col-span-full cursor-row-resize z-20 row-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3">
<div class="hidden lg:block gutter-2 col-span-full cursor-row-resize z-20 row-2 bg-gray-50 text-center text-gray-300 text-sm leading-3">
•••
</div>
<div
class="hidden lg:block z-20 bg-white @container"
class="hidden lg:flex flex-col z-20 bg-white @container max-h-full min-h-0"
data-tg-title={"#{gettext("Settings")}"}
data-tg-order="3"
data-tg-tour={"<p class='mb-3'>#{gettext("You can control each setting for the presentation (showing on the big screen) and on the attendee's room.")}</p><p class='opacity-50 text-xs'>#{gettext("Use the associated keyboard shortcuts for quick toggling of these settings.")}</p>"}
data-tg-group="manage"
>
<div class="w-full h-12 bg-gray-100 font-semibold text-xl flex items-center justify-center">
<%= gettext("Settings") %>
{gettext("Settings")}
</div>
<div class="grow @md:overflow-hidden overflow-auto min-h-0 ">
<.live_component
id="settings-pane"
module={ClaperWeb.EventLive.ManagerSettingsComponent}
create={@create}
state={@state}
current_interaction={@current_interaction}
/>
</div>
<.live_component
id="settings-pane"
module={ClaperWeb.EventLive.ManagerSettingsComponent}
create={@create}
state={@state}
current_interaction={@current_interaction}
/>
</div>
</div>
</div>

View File

@@ -26,12 +26,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
/>
</svg>
<span><%= gettext("Question") %></span>
<span>{gettext("Question")}</span>
</div>
<div :if={!@readonly} class="float-right mr-1">
<%= if @post.attendee_identifier do %>
<span class="text-yellow-500">
<%= link(
{link(
if @post.pinned do
gettext("Unpin")
else
@@ -41,11 +41,11 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
phx_click: "pin",
phx_value_id: @post.uuid,
phx_value_event_id: @event.uuid
) %>
)}
</span>
/
<span class="text-red-500">
<%= link(gettext("Ban"),
{link(gettext("Ban"),
to: "#",
phx_click: "ban",
phx_value_attendee_identifier: @post.attendee_identifier,
@@ -55,12 +55,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
"Blocking this user will delete all his messages and he will not be able to join again, confirm ?"
)
]
) %>
)}
</span>
/
<% else %>
<span class="text-yellow-500">
<%= link(
{link(
if @post.pinned do
gettext("Unpin")
else
@@ -70,11 +70,11 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
phx_click: "pin",
phx_value_id: @post.uuid,
phx_value_event_id: @event.uuid
) %>
)}
</span>
/
<span class="text-red-500">
<%= link(gettext("Ban"),
{link(gettext("Ban"),
to: "#",
phx_click: "ban",
phx_value_user_id: @post.user_id,
@@ -84,17 +84,17 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
"Blocking this user will delete all his messages and he will not be able to join again, confirm ?"
)
]
) %>
)}
</span>
/
<% end %>
<span class="text-red-500">
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_value_id: @post.uuid,
phx_value_event_id: @event.uuid
) %>
)}
</span>
</div>
@@ -114,12 +114,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
<div class="flex flex-col">
<%= if @post.name do %>
<p class="text-black text-sm font-semibold mr-2">
<%= @post.name %>
{@post.name}
</p>
<% end %>
<p class="text-xl">
<%= ClaperWeb.Helpers.format_body(@post.body) %>
{ClaperWeb.Helpers.format_body(@post.body)}
</p>
</div>
</div>
@@ -130,7 +130,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
<%= if @post.like_count> 0 do %>
<img src="/images/icons/thumb.svg" class="h-4" />
<span class="ml-1">
<%= @post.like_count %>
{@post.like_count}
</span>
<% end %>
</div>
@@ -138,7 +138,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
<%= if @post.love_count> 0 do %>
<img src="/images/icons/heart.svg" class="h-4" />
<span class="ml-1">
<%= @post.love_count %>
{@post.love_count}
</span>
<% end %>
</div>
@@ -146,7 +146,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
<%= if @post.lol_count> 0 do %>
<img src="/images/icons/laugh.svg" class="h-4" />
<span class="ml-1">
<%= @post.lol_count %>
{@post.lol_count}
</span>
<% end %>
</div>

View File

@@ -12,13 +12,13 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
~H"""
<div
id={"#{@id}"}
class={"#{if @quiz.show_results, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
class={"#{if @quiz.show_results, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black/90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
>
<div class="w-full md:w-1/2 mx-auto h-full">
<p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
<span :if={@current_question_idx < 0}><%= @quiz.title %></span>
<span :if={@current_question_idx < 0}>{@quiz.title}</span>
<span :if={@current_question_idx >= 0}>
<%= Enum.at(@quiz.quiz_questions, @current_question_idx).content %>
{Enum.at(@quiz.quiz_questions, @current_question_idx).content}
</span>
</p>
@@ -26,9 +26,9 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
:if={@current_question_idx == -1}
class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col text-white text-center"}
>
<p class="font-semibold text-2xl"><%= gettext("Average score") %>:</p>
<p class="font-semibold text-2xl">{gettext("Average score")}:</p>
<p class="font-semibold text-7xl">
<%= Claper.Quizzes.calculate_average_score(@quiz.id) %>/<%= length(@quiz.quiz_questions) %>
{Claper.Quizzes.calculate_average_score(@quiz.id)}/{length(@quiz.quiz_questions)}
</p>
</div>
@@ -38,11 +38,11 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
>
<%= for {opt, _idx} <- Enum.with_index(Enum.at(@quiz.quiz_questions, @current_question_idx).quiz_question_opts) do %>
<div class={"bg-gray-500 px-5 py-5 rounded-xl flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"} #{if not opt.is_correct, do: ""}"}>
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
</div>
<div class="flex space-x-3 justify-between w-full items-center z-10 text-left">
<span class="flex-1 pr-2 text-3xl"><%= opt.content %></span>
<span class="text-xl"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
<span class="flex-1 pr-2 text-3xl">{opt.content}</span>
<span class="text-xl">{opt.percentage}% ({opt.response_count})</span>
</div>
</div>
<% end %>

View File

@@ -5,7 +5,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
assigns = assigns |> assign_new(:show_shortcut, fn -> true end)
~H"""
<div class="grid grid-cols-1 @md:grid-cols-2 @md:space-x-5 px-5 py-3">
<div class="grid grid-cols-1 @md:grid-cols-2 @md:space-x-5 px-5 py-3 h-full mb-10">
<div>
<div class="flex items-center space-x-2 font-semibold text-lg">
<svg
@@ -19,7 +19,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
<path d="M10.766 7.51a.75.75 0 0 0-1.37.365l-.492 6.861a.75.75 0 0 0 1.204.65l1.043-.799.985 3.678a.75.75 0 0 0 1.45-.388l-.978-3.646 1.292.204a.75.75 0 0 0 .74-1.16l-3.874-5.764Z" />
</svg>
<span><%= gettext("Interaction") %></span>
<span>{gettext("Interaction")}</span>
</div>
<%= case @current_interaction do %>
@@ -59,10 +59,10 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</svg>
<span :if={@state.poll_visible}>
<%= gettext("Hide results on presentation") %>
{gettext("Hide results on presentation")}
</span>
<span :if={!@state.poll_visible}>
<%= gettext("Show results on presentation") %>
{gettext("Show results on presentation")}
</span>
<code
:if={@show_shortcut}
@@ -109,10 +109,10 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</svg>
<span :if={@current_interaction.show_results}>
<%= gettext("Hide results on presentation") %>
{gettext("Hide results on presentation")}
</span>
<span :if={!@current_interaction.show_results}>
<%= gettext("Show results on presentation") %>
{gettext("Show results on presentation")}
</span>
<code
:if={@show_shortcut}
@@ -141,7 +141,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</svg>
<span>
<%= gettext("Review questions") %>
{gettext("Review questions")}
</span>
<div></div>
</ClaperWeb.Component.Input.check_button>
@@ -165,7 +165,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
</svg>
<span>
<%= gettext("Previous") %>
{gettext("Previous")}
</span>
</ClaperWeb.Component.Input.check_button>
<ClaperWeb.Component.Input.check_button
@@ -173,7 +173,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
key={:next_quiz_question}
>
<span>
<%= gettext("Next") %>
{gettext("Next")}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -198,352 +198,409 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
<div class="flex space-x-2 items-center mt-3"></div>
</div>
<div class="grid grid-cols-1 space-y-5">
<div class="grid grid-cols-1 space-y-1.5">
<div class="flex items-center space-x-2 font-semibold text-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M1 2.75A.75.75 0 0 1 1.75 2h16.5a.75.75 0 0 1 0 1.5H18v8.75A2.75 2.75 0 0 1 15.25 15h-1.072l.798 3.06a.75.75 0 0 1-1.452.38L13.41 18H6.59l-.114.44a.75.75 0 0 1-1.452-.38L5.823 15H4.75A2.75 2.75 0 0 1 2 12.25V3.5h-.25A.75.75 0 0 1 1 2.75ZM7.373 15l-.391 1.5h6.037l-.392-1.5H7.373ZM13.25 5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm-6.5 4a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 6.75 9Zm4-1.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"
clip-rule="evenodd"
/>
</svg>
<div class="h-full overflow-visible @md:overflow-auto">
<div class="grid grid-cols-1 space-y-5">
<div class="grid grid-cols-1 space-y-1.5">
<div class="flex items-center space-x-2 font-semibold text-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M1 2.75A.75.75 0 0 1 1.75 2h16.5a.75.75 0 0 1 0 1.5H18v8.75A2.75 2.75 0 0 1 15.25 15h-1.072l.798 3.06a.75.75 0 0 1-1.452.38L13.41 18H6.59l-.114.44a.75.75 0 0 1-1.452-.38L5.823 15H4.75A2.75 2.75 0 0 1 2 12.25V3.5h-.25A.75.75 0 0 1 1 2.75ZM7.373 15l-.391 1.5h6.037l-.392-1.5H7.373ZM13.25 5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm-6.5 4a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 6.75 9Zm4-1.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Presentation") %></span>
<span>{gettext("Presentation")}</span>
</div>
<div class="flex space-x-1 items-center">
<ClaperWeb.Component.Input.check_button
key={:join_screen_visible}
checked={@state.join_screen_visible}
shortcut={if @create == nil, do: "Q", else: nil}
>
<svg
:if={!@state.join_screen_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
</svg>
<svg
:if={@state.join_screen_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 4h1a1 1 0 0 1 1 1v1m-.297 3.711a1 1 0 0 1 -.703 .289h-4a1 1 0 0 1 -1 -1v-4c0 -.275 .11 -.524 .29 -.705" /><path d="M7 17v.01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7v.01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7v.01" /><path d="M20 14v.01" /><path d="M14 14v3" /><path d="M14 20h3" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.join_screen_visible}>
{gettext("Show instructions to join")}
</span>
<span :if={@state.join_screen_visible}>
{gettext("Hide instructions to join")}
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
q
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div class="flex space-x-2 items-center">
<ClaperWeb.Component.Input.check_button
key={:chat_visible}
checked={@state.chat_visible}
shortcut={if @create == nil, do: "W", else: nil}
>
<svg
:if={!@state.chat_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<svg
:if={@state.chat_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.chat_visible}>{gettext("Show messages")}</span>
<span :if={@state.chat_visible}>{gettext("Hide messages")}</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
w
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center"}
title={
if !@state.chat_visible,
do: gettext("Show messages to change this option"),
else: nil
}
>
<ClaperWeb.Component.Input.check_button
key={:show_only_pinned}
checked={@state.show_only_pinned}
disabled={!@state.chat_visible}
shortcut={if @create == nil, do: "E", else: nil}
>
<svg
:if={!@state.show_only_pinned}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h4.5" /><path d="M10.325 19.605l-2.325 1.395v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
</svg>
<svg
:if={@state.show_only_pinned}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<div>
<span :if={!@state.show_only_pinned}>
{gettext("Show only pinned messages")}
</span>
<span :if={@state.show_only_pinned}>{gettext("Show all messages")}</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
e
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div>
<ClaperWeb.Component.Input.check_button
key={:show_attendee_count}
checked={@state.show_attendee_count}
shortcut={if @create == nil, do: "R", else: nil}
>
<svg
:if={!@state.show_attendee_count}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path
fill="currentColor"
d="M12 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4"
/>
</svg>
<svg
:if={@state.show_attendee_count}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path
fill="currentColor"
d="M12 4a4 4 0 0 1 4 4c0 1.95-1.4 3.58-3.25 3.93L8.07 7.25A4.004 4.004 0 0 1 12 4m.28 10l6 6L20 21.72L18.73 23l-3-3H4v-2c0-1.84 2.5-3.39 5.87-3.86L2.78 7.05l1.27-1.27zM20 18v1.18l-4.86-4.86C18 14.93 20 16.35 20 18"
/>
</svg>
<div>
<span :if={!@state.show_attendee_count}>
{gettext("Show attendee count")}
</span>
<span :if={@state.show_attendee_count}>
{gettext("Hide attendee count")}
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
r
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
</div>
<div class="flex space-x-1 items-center mt-3">
<ClaperWeb.Component.Input.check_button
key={:join_screen_visible}
checked={@state.join_screen_visible}
shortcut={if @create == nil, do: "Q", else: nil}
<div class="grid grid-cols-1 space-y-1.5">
<div class="flex items-center space-x-2 font-semibold text-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path d="M8 16.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" />
<path
fill-rule="evenodd"
d="M4 4a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4Zm4-1.5v.75c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V2.5h1A1.5 1.5 0 0 1 14.5 4v12a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 16V4A1.5 1.5 0 0 1 7 2.5h1Z"
clip-rule="evenodd"
/>
</svg>
<span>{gettext("Attendees")}</span>
</div>
<div class="flex space-x-2 items-center">
<ClaperWeb.Component.Input.check_button
key={:chat_enabled}
checked={@state.chat_enabled}
shortcut={if @create == nil, do: "A", else: nil}
>
<svg
:if={!@state.chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<svg
:if={@state.chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.chat_enabled}>{gettext("Enable messages")}</span>
<span :if={@state.chat_enabled}>{gettext("Disable messages")}</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
a
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center"}
title={
if !@state.chat_enabled,
do: gettext("Enable messages to change this option"),
else: nil
}
>
<svg
:if={!@state.join_screen_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
<ClaperWeb.Component.Input.check_button
key={:anonymous_chat_enabled}
checked={@state.anonymous_chat_enabled}
disabled={!@state.chat_enabled}
shortcut={if @create == nil, do: "S", else: nil}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
</svg>
<svg
:if={!@state.anonymous_chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h18" /><path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M10 17h4" />
</svg>
<svg
:if={@state.anonymous_chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h8m4 0h6" /><path d="M5 11v-4c0 -.571 .16 -1.105 .437 -1.56m2.563 -1.44h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M14.88 14.877a3 3 0 1 0 4.239 4.247m.59 -3.414a3.012 3.012 0 0 0 -1.425 -1.422" /><path d="M10 17h4" /><path d="M3 3l18 18" />
</svg>
<svg
:if={@state.join_screen_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 4h1a1 1 0 0 1 1 1v1m-.297 3.711a1 1 0 0 1 -.703 .289h-4a1 1 0 0 1 -1 -1v-4c0 -.275 .11 -.524 .29 -.705" /><path d="M7 17v.01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7v.01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7v.01" /><path d="M20 14v.01" /><path d="M14 14v3" /><path d="M14 20h3" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.join_screen_visible}>
<%= gettext("Show instructions to join") %>
</span>
<span :if={@state.join_screen_visible}>
<%= gettext("Hide instructions to join") %>
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
q
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div>
<span :if={!@state.anonymous_chat_enabled}>
{gettext("Allow anonymous messages")}
</span>
<span :if={@state.anonymous_chat_enabled}>
{gettext("Deny anonymous messages")}
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
s
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div class="flex space-x-2 items-center mt-3">
<ClaperWeb.Component.Input.check_button
key={:chat_visible}
checked={@state.chat_visible}
shortcut={if @create == nil, do: "W", else: nil}
>
<svg
:if={!@state.chat_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
<div class="flex space-x-2 items-center">
<ClaperWeb.Component.Input.check_button
key={:message_reaction_enabled}
checked={@state.message_reaction_enabled}
shortcut={if @create == nil, do: "D", else: nil}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<svg
:if={@state.chat_visible}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.chat_visible}><%= gettext("Show messages") %></span>
<span :if={@state.chat_visible}><%= gettext("Hide messages") %></span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
w
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<svg
:if={!@state.message_reaction_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
<svg
:if={@state.message_reaction_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3l18 18" /><path d="M19.5 12.572l-1.5 1.428m-2 2l-4 4l-7.5 -7.428a5 5 0 0 1 -1.288 -5.068a4.976 4.976 0 0 1 1.788 -2.504m3 -1c1.56 0 3.05 .727 4 2a5 5 0 1 1 7.5 6.572" />
</svg>
<div
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"}
title={
if !@state.chat_visible,
do: gettext("Show messages to change this option"),
else: nil
}
>
<ClaperWeb.Component.Input.check_button
key={:show_only_pinned}
checked={@state.show_only_pinned}
disabled={!@state.chat_visible}
shortcut={if @create == nil, do: "E", else: nil}
>
<svg
:if={!@state.show_only_pinned}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h4.5" /><path d="M10.325 19.605l-2.325 1.395v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
</svg>
<svg
:if={@state.show_only_pinned}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<div>
<span :if={!@state.show_only_pinned}>
<%= gettext("Show only pinned messages") %>
</span>
<span :if={@state.show_only_pinned}><%= gettext("Show all messages") %></span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
e
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
</div>
<div class="grid grid-cols-1 space-y-1.5">
<div class="flex items-center space-x-2 font-semibold text-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path d="M8 16.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" />
<path
fill-rule="evenodd"
d="M4 4a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4Zm4-1.5v.75c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V2.5h1A1.5 1.5 0 0 1 14.5 4v12a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 16V4A1.5 1.5 0 0 1 7 2.5h1Z"
clip-rule="evenodd"
/>
</svg>
<span><%= gettext("Attendees") %></span>
</div>
<div class="flex space-x-2 items-center mt-3">
<ClaperWeb.Component.Input.check_button
key={:chat_enabled}
checked={@state.chat_enabled}
shortcut={if @create == nil, do: "A", else: nil}
>
<svg
:if={!@state.chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
</svg>
<svg
:if={@state.chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.chat_enabled}><%= gettext("Enable messages") %></span>
<span :if={@state.chat_enabled}><%= gettext("Disable messages") %></span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
a
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"}
title={
if !@state.chat_enabled,
do: gettext("Enable messages to change this option"),
else: nil
}
>
<ClaperWeb.Component.Input.check_button
key={:anonymous_chat_enabled}
checked={@state.anonymous_chat_enabled}
disabled={!@state.chat_enabled}
shortcut={if @create == nil, do: "S", else: nil}
>
<svg
:if={!@state.anonymous_chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h18" /><path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M10 17h4" />
</svg>
<svg
:if={@state.anonymous_chat_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h8m4 0h6" /><path d="M5 11v-4c0 -.571 .16 -1.105 .437 -1.56m2.563 -1.44h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M14.88 14.877a3 3 0 1 0 4.239 4.247m.59 -3.414a3.012 3.012 0 0 0 -1.425 -1.422" /><path d="M10 17h4" /><path d="M3 3l18 18" />
</svg>
<div>
<span :if={!@state.anonymous_chat_enabled}>
<%= gettext("Allow anonymous messages") %>
</span>
<span :if={@state.anonymous_chat_enabled}>
<%= gettext("Deny anonymous messages") %>
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
s
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
<div class="flex space-x-2 items-center mt-3">
<ClaperWeb.Component.Input.check_button
key={:message_reaction_enabled}
checked={@state.message_reaction_enabled}
shortcut={if @create == nil, do: "D", else: nil}
>
<svg
:if={!@state.message_reaction_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
<svg
:if={@state.message_reaction_enabled}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3l18 18" /><path d="M19.5 12.572l-1.5 1.428m-2 2l-4 4l-7.5 -7.428a5 5 0 0 1 -1.288 -5.068a4.976 4.976 0 0 1 1.788 -2.504m3 -1c1.56 0 3.05 .727 4 2a5 5 0 1 1 7.5 6.572" />
</svg>
<div>
<span :if={!@state.message_reaction_enabled}>
<%= gettext("Enable reactions") %>
</span>
<span :if={@state.message_reaction_enabled}>
<%= gettext("Disable reactions") %>
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
d
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
<div>
<span :if={!@state.message_reaction_enabled}>
{gettext("Enable reactions")}
</span>
<span :if={@state.message_reaction_enabled}>
{gettext("Disable reactions")}
</span>
</div>
<code
:if={@show_shortcut}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
>
d
</code>
<div :if={!@show_shortcut}></div>
</ClaperWeb.Component.Input.check_button>
</div>
</div>
</div>
</div>

View File

@@ -25,7 +25,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="font-bold"><%= gettext("See current poll") %></span>
<span class="font-bold">{gettext("See current poll")}</span>
</div>
</div>
</div>
@@ -44,12 +44,12 @@ defmodule ClaperWeb.EventLive.PollComponent do
</svg>
</div>
<p class="text-sm text-gray-400 my-1"><%= gettext("Current poll") %></p>
<p class="text-white text-xl font-semibold mb-2"><%= @poll.title %></p>
<p class="text-sm text-gray-400 my-1">{gettext("Current poll")}</p>
<p class="text-white text-xl font-semibold mb-2">{@poll.title}</p>
<%= if @poll.multiple do %>
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one or multiple options") %></p>
<p class="text-gray-400 text-sm mb-4">{gettext("Select one or multiple options")}</p>
<% else %>
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one option") %></p>
<p class="text-gray-400 text-sm mb-4">{gettext("Select one option")}</p>
<% end %>
</div>
<div>
@@ -60,7 +60,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
<button class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white">
<div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
>
</div>
<div class="flex space-x-3 items-center z-10 text-left">
@@ -78,10 +78,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
</span>
<% end %>
<% end %>
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
<span :if={@show_results} class="text-sm z-10">
<%= opt.percentage %>% (<%= opt.vote_count %>)
{opt.percentage}% ({opt.vote_count})
</span>
</button>
<% else %>
@@ -93,7 +93,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
>
<div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
>
</div>
<div class="flex space-x-3 items-center z-10 text-left">
@@ -111,10 +111,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
</span>
<% end %>
<% end %>
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
<span :if={@show_results} class="text-sm z-10">
<%= opt.percentage %>% (<%= opt.vote_count %>)
{opt.percentage}% ({opt.vote_count})
</span>
</button>
<% end %>
@@ -124,7 +124,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
<%= if (length @selected_poll_opt) == 0 || (length @current_poll_vote) > 0 do %>
<button class="px-3 py-2 text-white font-medium bg-gray-500 rounded-md mt-3 mb-4 cursor-default">
<%= gettext("Vote") %>
{gettext("Vote")}
</button>
<% else %>
<button
@@ -132,7 +132,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
phx-disable-with="..."
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
>
<%= gettext("Vote") %>
{gettext("Vote")}
</button>
<% end %>
</div>

View File

@@ -25,12 +25,12 @@ defmodule ClaperWeb.EventLive.PostComponent do
<%= if @post.name || leader?(@post, @event, @leaders) || pinned?(@post) do %>
<div class="inline-flex items-center">
<%= if @post.name do %>
<p class="text-white text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
<p class="text-white text-xs font-semibold mb-2 mr-2">{@post.name}</p>
<% end %>
<%= if leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
<img src="/images/icons/star.svg" class="h-3" />
<span><%= gettext("Host") %></span>
<span>{gettext("Host")}</span>
</div>
<% end %>
@@ -56,7 +56,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
</path>
</svg>
<span><%= gettext("Pinned") %></span>
<span>{gettext("Pinned")}</span>
</div>
<% end %>
</div>
@@ -67,34 +67,34 @@ defmodule ClaperWeb.EventLive.PostComponent do
class="hidden absolute right-4 top-7 bg-white rounded-lg px-5 py-2 animate__faster"
>
<span class="text-red-500">
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_value_id: @post.uuid,
phx_value_event_id: @event.uuid,
data: [confirm: gettext("Are you sure?")]
) %>
)}
</span>
</div>
<p><%= ClaperWeb.Helpers.format_body(@post.body) %></p>
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
<div class="flex h-6 text-sm float-right text-white space-x-2">
<%= if @post.like_count > 0 do %>
<div class="flex px-1 items-center">
<img src="/images/icons/thumb.svg" class="h-4" />
<span class="ml-1 text-white"><%= @post.like_count %></span>
<span class="ml-1 text-white">{@post.like_count}</span>
</div>
<% end %>
<%= if @post.love_count > 0 do %>
<div class="flex px-1 items-center">
<img src="/images/icons/heart.svg" class="h-4" />
<span class="ml-1 text-white"><%= @post.love_count %></span>
<span class="ml-1 text-white">{@post.love_count}</span>
</div>
<% end %>
<%= if @post.lol_count > 0 do %>
<div class="flex px-1 items-center">
<img src="/images/icons/laugh.svg" class="h-4" />
<span class="ml-1 text-white"><%= @post.lol_count %></span>
<span class="ml-1 text-white">{@post.lol_count}</span>
</div>
<% end %>
</div>
@@ -104,12 +104,12 @@ defmodule ClaperWeb.EventLive.PostComponent do
<%= if @post.name || leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center">
<%= if @post.name do %>
<p class="text-black text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
<p class="text-black text-xs font-semibold mb-2 mr-2">{@post.name}</p>
<% end %>
<%= if leader?(@post, @event, @leaders) do %>
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
<img src="/images/icons/star.svg" class="h-3" />
<span><%= gettext("Host") %></span>
<span>{gettext("Host")}</span>
</div>
<% end %>
</div>
@@ -139,13 +139,13 @@ defmodule ClaperWeb.EventLive.PostComponent do
class="hidden absolute right-4 top-7 bg-gray-900 rounded-lg px-5 py-2"
>
<span class="text-red-500">
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_value_id: @post.uuid,
phx_value_event_id: @event.uuid,
data: [confirm: gettext("Are you sure?")]
) %>
)}
</span>
</div>
<% end %>
@@ -172,11 +172,11 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
</path>
</svg>
<span><%= gettext("Pinned") %></span>
<span>{gettext("Pinned")}</span>
</div>
<% end %>
<p><%= ClaperWeb.Helpers.format_body(@post.body) %></p>
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
<div class="flex h-6 text-xs float-right space-x-2">
<%= if @reaction_enabled do %>
@@ -189,7 +189,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
<img src="/images/icons/thumb.svg" class="h-4" />
<%= if @post.like_count > 0 do %>
<span class="ml-1"><%= @post.like_count %></span>
<span class="ml-1">{@post.like_count}</span>
<% end %>
</button>
<% else %>
@@ -203,7 +203,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
<img src="/images/icons/thumb.svg" class="h-4" />
</span>
<%= if @post.like_count > 0 do %>
<span class="ml-1"><%= @post.like_count %></span>
<span class="ml-1">{@post.like_count}</span>
<% end %>
</button>
<% end %>
@@ -216,7 +216,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
<img src="/images/icons/heart.svg" class="h-4" />
<%= if @post.love_count > 0 do %>
<span class="ml-1"><%= @post.love_count %></span>
<span class="ml-1">{@post.love_count}</span>
<% end %>
</button>
<% else %>
@@ -228,7 +228,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
<img src="/images/icons/heart.svg" class="h-4" />
<%= if @post.love_count > 0 do %>
<span class="ml-1"><%= @post.love_count %></span>
<span class="ml-1">{@post.love_count}</span>
<% end %>
</button>
<% end %>
@@ -241,7 +241,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
<img src="/images/icons/laugh.svg" class="h-4" />
<%= if @post.lol_count > 0 do %>
<span class="ml-1"><%= @post.lol_count %></span>
<span class="ml-1">{@post.lol_count}</span>
<% end %>
</button>
<% else %>
@@ -253,7 +253,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
>
<img src="/images/icons/laugh.svg" class="h-4" />
<%= if @post.lol_count > 0 do %>
<span class="ml-1"><%= @post.lol_count %></span>
<span class="ml-1">{@post.lol_count}</span>
<% end %>
</button>
<% end %>

View File

@@ -6,6 +6,8 @@ defmodule ClaperWeb.EventLive.Presenter do
alias Claper.Polls.Poll
alias Claper.Forms.Form
alias Claper.Quizzes.Quiz
alias Claper.Presentations
@impl true
def mount(%{"code" => code} = params, session, socket) do
with %{"locale" => locale} <- session do
@@ -70,7 +72,7 @@ defmodule ClaperWeb.EventLive.Presenter do
end
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
end
defp leader?(_socket, _event), do: false

View File

@@ -23,7 +23,7 @@
>
<div class="h-full bg-white text-black text-center flex flex-col items-center justify-center">
<span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl">
<%= gettext("Scan to interact in real-time") %>
{gettext("Scan to interact in real-time")}
</span>
<div
phx-hook="QRCode"
@@ -35,10 +35,10 @@
>
</div>
<span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl">
<%= gettext("Or go to %{url} and use the code:", url: @host) %>
{gettext("Or go to %{url} and use the code:", url: @host)}
</span>
<span class="font-semibold mb-10 sm:text-5xl md:text-6xl lg:text-8xl">
#<%= String.upcase(@event.code) %>
#{String.upcase(@event.code)}
</span>
</div>
</div>
@@ -46,11 +46,11 @@
<%= if @current_poll do %>
<div
id="poll"
class={"#{if @state.poll_visible, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
class={"#{if @state.poll_visible, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black/90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
>
<div class="w-full md:w-1/2 mx-auto h-full">
<p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
<%= @current_poll.title %>
{@current_poll.title}
</p>
<div class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col"}>
@@ -59,16 +59,16 @@
<div class={"#{if @iframe, do: "py-1", else: "py-4"} bg-gray-500 px-6 rounded-3xl flex justify-between items-center relative text-white"}>
<div
style={"width: #{opt.percentage}%;"}
class="bg-gradient-to-r from-primary-500 to-secondary-500 rounded-3xl h-full absolute left-0 transition-all"
class="bg-linear-to-r from-primary-500 to-secondary-500 rounded-3xl h-full absolute left-0 transition-all"
>
</div>
<div class="flex space-x-3 z-10 text-left">
<span class={"#{if @iframe, do: "text-base", else: "text-2xl"} flex-1 font-bold pr-2"}>
<%= opt.content %>
{opt.content}
</span>
</div>
<span class={"#{if @iframe, do: "text-base", else: "text-2xl"} z-10 font-bold"}>
<%= opt.percentage %>% (<%= opt.vote_count %>)
{opt.percentage}% ({opt.vote_count})
</span>
</div>
<% end %>
@@ -107,10 +107,10 @@
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
<%= if post.name do %>
<p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
<%= post.name %>
{post.name}
</p>
<% end %>
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}>{post.body}</p>
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
@@ -120,7 +120,7 @@
src="/images/icons/thumb.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.like_count %></span>
<span class="ml-1">{post.like_count}</span>
<% end %>
</div>
<div class="flex items-center">
@@ -129,7 +129,7 @@
src="/images/icons/heart.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.love_count %></span>
<span class="ml-1">{post.love_count}</span>
<% end %>
</div>
<div class="flex items-center">
@@ -138,7 +138,7 @@
src="/images/icons/laugh.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.lol_count %></span>
<span class="ml-1">{post.lol_count}</span>
<% end %>
</div>
</div>
@@ -154,10 +154,10 @@
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
<%= if post.name do %>
<p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
<%= post.name %>
{post.name}
</p>
<% end %>
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}>{post.body}</p>
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
@@ -167,7 +167,7 @@
src="/images/icons/thumb.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.like_count %></span>
<span class="ml-1">{post.like_count}</span>
<% end %>
</div>
<div class="flex items-center">
@@ -176,7 +176,7 @@
src="/images/icons/heart.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.love_count %></span>
<span class="ml-1">{post.love_count}</span>
<% end %>
</div>
<div class="flex items-center">
@@ -185,7 +185,7 @@
src="/images/icons/laugh.svg"
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
/>
<span class="ml-1"><%= post.lol_count %></span>
<span class="ml-1">{post.lol_count}</span>
<% end %>
</div>
</div>
@@ -197,7 +197,7 @@
<% end %>
</div>
<div
class={"gutter-1 row-span-full cursor-col-resize col-[2] text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: "hidden"}"}
class={"gutter-1 row-span-full cursor-col-resize col-2 text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: "hidden"}"}
style="writing-mode: vertical-rl"
>
•••
@@ -216,27 +216,17 @@
</div>
<% end %>
<div class={"#{if @current_embed, do: "hidden", else: ""} text-center"} id="slider">
<%= for index <- 1..max(1, @event.presentation_file.length) do %>
<%= if @event.presentation_file.length > 0 do %>
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
<img
class="max-h-screen !w-auto"
src={"/uploads/#{@event.presentation_file.hash}/#{index}.jpg"}
/>
<% else %>
<img
class=" max-h-screen !w-auto"
src={"https://#{Application.get_env(:claper, :presentations) |> Keyword.get(:aws_bucket)}.s3.#{Application.get_env(:ex_aws, :region)}.amazonaws.com/presentations/#{@event.presentation_file.hash}/#{index}.jpg"}
/>
<% end %>
<% end %>
<% end %>
<img
:for={src <- Presentations.get_slide_urls(@event.presentation_file)}
src={src}
class="max-h-screen w-auto!"
/>
</div>
</div>
</div>
<!-- ONLINE BADGE -->
<div
:if={!@iframe}
:if={!@iframe && @state.show_attendee_count}
class="absolute z-20 bottom-5 right-5 px-4 pt-3 pb-1 rounded-md bg-black shadow-md text-white flex-1"
>
<div id="reacts" phx-hook="GlobalReacts" data-class-name="h-24" phx-update="ignore"></div>
@@ -244,7 +234,7 @@
<div class="inline-flex justify-between items-center text-white text-2xl">
<img src="/images/icons/online-users.svg" class="h-12 mr-2" />
<span id="counter" phx-hook="UpdateAttendees" phx-update="ignore">
<%= @attendees_nb %>
{@attendees_nb}
</span>
</div>
</div>

View File

@@ -38,7 +38,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span class="font-bold"><%= gettext("See current quiz") %></span>
<span class="font-bold">{gettext("See current quiz")}</span>
</div>
</div>
</div>
@@ -57,13 +57,13 @@ defmodule ClaperWeb.EventLive.QuizComponent do
</svg>
</div>
<p class="text-sm text-gray-400 my-1"><%= gettext("Current quiz") %></p>
<p class="text-sm text-gray-400 my-1">{gettext("Current quiz")}</p>
<%= if is_nil(@current_question) do %>
<p class="text-white text-xl font-semibold mb-2"><%= @quiz.title %></p>
<p class="text-white text-xl font-semibold mb-2">{@quiz.title}</p>
<% else %>
<p class="text-white text-xl font-semibold mb-2"><%= @current_question.content %></p>
<p class="text-white text-xl font-semibold mb-2">{@current_question.content}</p>
<p class="text-gray-400 text-sm mb-4">
<%= @current_quiz_question_idx + 1 %>/<%= length(@quiz.quiz_questions) %>
{@current_quiz_question_idx + 1}/{length(@quiz.quiz_questions)}
</p>
<% end %>
</div>
@@ -81,10 +81,10 @@ defmodule ClaperWeb.EventLive.QuizComponent do
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
</div>
<% end %>
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
<span class="text-sm"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
<span class="text-sm">{opt.percentage}% ({opt.response_count})</span>
</div>
</div>
<% else %>
@@ -93,7 +93,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
phx-value-opt={opt.id}
class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white"
>
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
</div>
<div class="flex space-x-3 items-center z-10 text-left">
<%= if Enum.member?(@selected_quiz_question_opts, opt) do %>
@@ -102,7 +102,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
</span>
<% end %>
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
</button>
<% end %>
@@ -110,18 +110,18 @@ defmodule ClaperWeb.EventLive.QuizComponent do
<% else %>
<div class="text-gray-400 flex flex-col items-center justify-center font-semibold text-lg mt-4">
<%= if @quiz.show_results do %>
<p><%= gettext("Your score") %></p>
<p>{gettext("Your score")}</p>
<p class="text-6xl font-bold mt-2">
<%= elem(@quiz_score, 0) %>/<%= elem(@quiz_score, 1) %>
{elem(@quiz_score, 0)}/{elem(@quiz_score, 1)}
</p>
<button
phx-click="show-quiz-results"
class="mt-7 px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
>
<%= gettext("Show results") %>
{gettext("Show results")}
</button>
<% else %>
<p><%= gettext("Waiting for results...") %></p>
<p>{gettext("Waiting for results...")}</p>
<svg
class="w-32 h-32 mt-4"
viewBox="0 0 360 360"
@@ -148,7 +148,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
<div :if={not @is_submitted} class="flex justify-between items-baseline w-full h-12 mt-5">
<%= if @current_quiz_question_idx > 0 do %>
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
<%= gettext("Back") %>
{gettext("Back")}
</button>
<% end %>
@@ -158,21 +158,21 @@ defmodule ClaperWeb.EventLive.QuizComponent do
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
disabled={not @has_selection}
>
<%= gettext("Next") %>
{gettext("Next")}
</button>
<% else %>
<%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
<div class="w-full flex items-center justify-between">
<div class="text-white text-sm font-semibold">
<%= gettext("Please sign in to submit your answers") %>
{gettext("Please sign in to submit your answers")}
</div>
<%= link(
{link(
gettext("Sign in"),
target: "_blank",
to: ~p"/users/log_in",
class:
"inline px-3 py-2 text-white font-medium rounded-md h-full bg-primary-400 hover:bg-primary-500"
) %>
)}
</div>
<% else %>
<button
@@ -180,7 +180,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
disabled={not @has_selection}
>
<%= gettext("Submit") %>
{gettext("Submit")}
</button>
<% end %>
<% end %>
@@ -195,7 +195,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
>
<%= if (@current_quiz_question_idx > 0 && @current_quiz_question_idx <= length(@quiz.quiz_questions) - 1) do %>
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
<%= gettext("Back") %>
{gettext("Back")}
</button>
<% else %>
<div class="w-1/2"></div>
@@ -206,7 +206,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
phx-click="next-question"
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md h-full"
>
<%= gettext("Next") %>
{gettext("Next")}
</button>
</div>
</div>

View File

@@ -109,7 +109,7 @@ defmodule ClaperWeb.EventLive.Show do
defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event)
when is_map(current_user) do
is_leader =
current_user.id == event.user_id || Claper.Events.leaded_by?(current_user.email, event)
current_user.id == event.user_id || Claper.Events.led_by?(current_user.email, event)
socket |> assign(:is_leader, is_leader)
end
@@ -187,7 +187,7 @@ defmodule ClaperWeb.EventLive.Show do
end
@impl true
def handle_info({:event_terminated, _event}, socket) do
def handle_info({:event_terminated, _event_uuid}, socket) do
{:noreply,
socket
|> put_flash(:error, gettext("This event has been terminated"))
@@ -291,10 +291,6 @@ defmodule ClaperWeb.EventLive.Show do
@impl true
def handle_info({:form_updated, %Claper.Forms.Form{enabled: true} = form}, socket) do
{:noreply,
socket
|> update(:current_interaction, fn _current_interaction -> nil end)}
{:noreply,
socket
|> load_current_interaction(form, true)}
@@ -328,17 +324,6 @@ defmodule ClaperWeb.EventLive.Show do
|> load_current_interaction(quiz, true)}
end
@impl true
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
end
@impl true
def handle_info({:quiz_updated, %Claper.Quizzes.Quiz{enabled: true} = quiz}, socket) do
{:noreply,
socket
|> load_current_interaction(quiz, true)}
end
@impl true
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
{:noreply,
@@ -708,7 +693,7 @@ defmodule ClaperWeb.EventLive.Show do
)
when is_map(current_user) do
case Claper.Quizzes.submit_quiz(
current_user.id,
current_user,
opts,
socket.assigns.current_interaction.id
) do

View File

@@ -1,10 +1,10 @@
<%= if @started || @is_leader do %>
<div class="relative min-h-screen lg:flex lg:flex-col lg:items-center lg:w-full bg-black lg:bg-gradient-to-tl from-primary-500 to-secondary-500">
<div class="relative min-h-screen lg:flex lg:flex-col lg:items-center lg:w-full bg-black lg:bg-linear-to-tl from-primary-500 to-secondary-500">
<div class="relative w-full">
<div
id="side-menu-shadow"
phx-click={toggle_side_menu()}
class="hidden fixed z-20 h-screen bg-black bg-opacity-70 w-full"
class="hidden fixed z-20 h-screen bg-black/70 w-full"
>
</div>
@@ -15,7 +15,7 @@
<div>
<img src="/images/logo-large-black.svg" class="h-16 my-3" />
<span class="font-bold text-xl"><%= @event.name %></span>
<span class="font-bold text-xl">{@event.name}</span>
</div>
<a
@@ -23,7 +23,7 @@
href={~p"/?disconnected_from=#{@event.uuid}"}
>
<img src="/images/icons/exit-outline.svg" class="h-5 mr-3" />
<span><%= gettext("Leave") %></span>
<span>{gettext("Leave")}</span>
</a>
</div>
</div>
@@ -35,23 +35,23 @@
>
<div id="banner" class="hidden w-full bg-gray-800 text-center" phx-hook="EmbeddedBanner">
<a href="https://claper.co" target="_blank" class="text-xs text-white py-3 w-full">
<%= gettext("Create your next presentation with") %>
{gettext("Create your next presentation with")}
<span class="underline">Claper</span>
</a>
</div>
<div class="flex justify-between items-center px-5 py-3">
<button
phx-click={toggle_side_menu()}
class="bg-black rounded-full text-sm px-3 py-1 bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 text-white uppercase flex items-center"
class="bg-black rounded-full text-sm px-3 py-1 bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] text-white uppercase flex items-center"
>
<img src="/images/icons/menu-outline.svg" class="h-6" />
<span class="ml-1">#<%= @event.code %></span>
<span class="ml-1">#{@event.code}</span>
</button>
<div class="inline-flex justify-between items-center text-white text-sm">
<img src="/images/icons/online-users.svg" class="h-6 mr-2" />
<span id="counter" phx-update="ignore" phx-hook="UpdateAttendees">
<%= @attendees_nb %>
{@attendees_nb}
</span>
</div>
</div>
@@ -162,7 +162,7 @@
<%= if @post_count == 0 && @state.chat_enabled do %>
<div class="text-2xl text-white block fixed bottom-32 left-0 w-full lg:w-1/3 lg:left-1/2 lg:transform lg:-translate-x-1/2 text-center opacity-30">
<span><%= gettext("Be the first to react !") %></span>
<span>{gettext("Be the first to react !")}</span>
<img src="/images/icons/arrow-white.svg" class="h-24 rotate-180 ml-12 mt-8" />
</div>
<% end %>
@@ -205,7 +205,7 @@
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M10 17h4"></path>
</svg>
<span><%= gettext("Anonymous") %></span>
<span>{gettext("Anonymous")}</span>
</button>
<% else %>
<button class="w-full bg-gray-900 opacity-50 text-left text-white px-3 py-2 rounded-md flex space-x-2 items-center cursor-default">
@@ -226,7 +226,7 @@
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M10 17h4"></path>
</svg>
<span><%= gettext("Anonymous") %> (<%= gettext("disabled") %>)</span>
<span>{gettext("Anonymous")} ({gettext("disabled")})</span>
</button>
<% end %>
@@ -246,13 +246,13 @@
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
</svg>
<span><%= gettext("Use your name") %></span>
<span>{gettext("Use your name")}</span>
</button>
<button
phx-click={toggle_nickname_popup()}
class="w-full text-left text-primary-500 text-sm px-3 py-0 rounded-md"
>
<%= gettext("Close") %>
{gettext("Close")}
</button>
</div>
@@ -275,15 +275,15 @@
backdrop-filter: blur(11.5px);
-webkit-backdrop-filter: blur(11.5px);"
>
<%= text_input(f, :name,
{text_input(f, :name,
class:
"bg-transparent outline-none w-full text-white h-10 placeholder-white resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
placeholder: gettext("Enter your name")
) %>
)}
<p class="font-semibold text-sm">
<%= error_tag(f, :name) %>
{error_tag(f, :name)}
</p>
<%= submit(gettext("Join"), class: "absolute right-5 top-2 p-2 bg-white rounded-md") %>
{submit(gettext("Join"), class: "absolute right-5 top-2 p-2 bg-white rounded-md")}
</div>
</.form>
<% else %>
@@ -326,9 +326,9 @@
/>
</svg>
<%= if @nickname && @nickname == "" do %>
<span><%= gettext("Anonymous") %></span>
<span>{gettext("Anonymous")}</span>
<% else %>
<span><%= @nickname %></span>
<span>{@nickname}</span>
<% end %>
</a>
</div>
@@ -338,15 +338,15 @@
</button>
<div class="flex space-x-2 items-center">
<%= textarea(f, :body,
{textarea(f, :body,
id: "postFormTA",
class:
"bg-transparent outline-none w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
placeholder: gettext("Ask, comment...")
) %>
)}
</div>
</div>
<%= submit("Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden") %>
{submit("Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden")}
</.form>
<% end %>
<% else %>
@@ -361,8 +361,8 @@
-webkit-backdrop-filter: blur(11.5px);"
>
<div class="flex space-x-2 items-center">
<div class="opacity-50 bg-transparent outline-none w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto">
<%= gettext("Messages deactivated") %>
<div class="opacity-50 bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto">
{gettext("Messages deactivated")}
</div>
</div>
</div>
@@ -420,7 +420,7 @@
<div class="flex bg-black h-screen">
<div class="flex items-center text-center lg:text-left justify-center lg:justify-start lg:px-12 w-full lg:w-1/2">
<div class="w-full mx-12 lg:w-auto lg:mx-0">
<h1 class="py-5 text-5xl font-semibold text-white md:text-6xl"><%= @event.name %></h1>
<h1 class="py-5 text-5xl font-semibold text-white md:text-6xl">{@event.name}</h1>
<h2
x-data={"{date: moment.utc('#{@event.started_at}').local().format('LLLL')}"}
@@ -432,36 +432,36 @@
<div class="text-white flex justify-between items-center mt-12">
<div class="flex flex-col items-center mr-10">
<span class="text-5xl font-bold">
<%= if @remaining_days < 10, do: "0" %><%= @remaining_days %>
{if @remaining_days < 10, do: "0"}{@remaining_days}
</span>
<span class="text-gray-400"><%= gettext("days") %></span>
<span class="text-gray-400">{gettext("days")}</span>
</div>
<div class="flex flex-col items-center mr-10">
<span class="text-5xl font-bold">
<%= if @remaining_hours < 10, do: "0" %><%= @remaining_hours %>
{if @remaining_hours < 10, do: "0"}{@remaining_hours}
</span>
<span class="text-gray-400"><%= gettext("hours") %></span>
<span class="text-gray-400">{gettext("hours")}</span>
</div>
<div class="flex flex-col items-center mr-10">
<span class="text-5xl font-bold">
<%= if @remaining_minutes < 10, do: "0" %><%= @remaining_minutes %>
{if @remaining_minutes < 10, do: "0"}{@remaining_minutes}
</span>
<span class="text-gray-400"><%= gettext("minutes") %></span>
<span class="text-gray-400">{gettext("minutes")}</span>
</div>
<div class="flex flex-col items-center">
<span class="text-5xl font-bold">
<%= if @remaining_seconds < 10, do: "0" %><%= @remaining_seconds %>
{if @remaining_seconds < 10, do: "0"}{@remaining_seconds}
</span>
<span class="text-gray-400"><%= gettext("seconds") %></span>
<span class="text-gray-400">{gettext("seconds")}</span>
</div>
</div>
</div>
</div>
<div class="hidden lg:block lg:w-1/2">
<div class="h-full object-cover bg-gradient-animate">
<div class="h-full bg-black text-white bg-opacity-50 text-center flex flex-col items-center justify-center">
<div class="h-full bg-black/50 text-white text-center flex flex-col items-center justify-center">
<span class="text-4xl font-semibold mb-10">
<%= gettext("Scan to interact in real-time") %>
{gettext("Scan to interact in real-time")}
</span>
<div
phx-hook="QRCode"
@@ -470,8 +470,8 @@
class="rounded-lg mx-auto bg-black h-64 w-64 p-12 flex items-center justify-center mb-14"
>
</div>
<span class="text-4xl font-semibold mb-10"><%= gettext("Or use the code:") %></span>
<span class="text-5xl font-semibold mb-10">#<%= String.upcase(@event.code) %></span>
<span class="text-4xl font-semibold mb-10">{gettext("Or use the code:")}</span>
<span class="text-5xl font-semibold mb-10">#{String.upcase(@event.code)}</span>
</div>
</div>
</div>

View File

@@ -12,8 +12,6 @@
form={f}
key={:title}
name={gettext("Title of your form")}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
@@ -24,8 +22,6 @@
<div class="flex items-center gap-x-2">
<ClaperWeb.Component.Input.text
form={i}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:name}
name={ngettext("Field %{count}", "Field %{count}", i.index + 1)}
autofocus="true"
@@ -33,13 +29,12 @@
/>
<ClaperWeb.Component.Input.select
form={i}
labelClass={if @dark, do: "text-white"}
array={[{:"#{gettext("Text")}", "text"}, {:"#{gettext("Email")}", "email"}]}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:type}
name={gettext("Type")}
required="true"
/>
<ClaperWeb.Component.Input.toggle form={i} key={:required} label={gettext("Required")} />
</div>
<%= if i.index >= 1 do %>
<button
@@ -92,15 +87,15 @@
<button
type="submit"
phx_disable_with="Loading..."
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= case @live_action do
{case @live_action do
:new -> gettext("Create")
:edit -> gettext("Save")
end %>
end}
</button>
<%= if @live_action == :edit do %>
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_target: @myself,
@@ -112,8 +107,8 @@
)
],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
<% end %>
</div>
</.form>

View File

@@ -15,20 +15,19 @@ defmodule ClaperWeb.ModalComponent do
phx-window-keydown={hide_modal()}
phx-key="escape"
phx-target={@myself}
phx-page-loading
>
<div class="flex items-center justify-center pt-4 px-4 pb-20 text-center sm:block sm:p-4">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
class="fixed inset-0 bg-gray-500/75 transition-opacity -z-10"
phx-click={hide_modal()}
phx-target={@myself}
aria-hidden="true"
>
</div>
<div class="inline-block align-middle bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all">
<div class="inline-block align-middle bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all relative">
<div
class="text-2xl text-gray-400 absolute right-5 top-3"
class="text-2xl text-gray-400 absolute right-5 top-3 cursor-pointer"
phx-click={hide_modal()}
phx-target={@myself}
>
@@ -36,16 +35,16 @@ defmodule ClaperWeb.ModalComponent do
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
<%= @title %>
{@title}
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>
<%= @description %>
{@description}
</p>
</div>
<div class="mt-2">
<p class="text-sm text-gray-500">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</p>
</div>
</div>
@@ -64,6 +63,6 @@ defmodule ClaperWeb.ModalComponent do
def hide_modal(js \\ %JS{}) do
js
|> JS.hide(to: "#modal", transition: "animate__animated animate__fadeOut", time: 300)
|> JS.push("hide", target: "#modal")
|> JS.push("hide", target: "#modal", page_loading: true)
end
end

View File

@@ -12,8 +12,6 @@
form={f}
key={:title}
name={gettext("Title of your poll")}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
@@ -24,8 +22,6 @@
<div class="flex-1">
<ClaperWeb.Component.Input.text
form={i}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:content}
name={ngettext("Choice %{count}", "Choice %{count}", i.index + 1)}
autofocus="true"
@@ -79,36 +75,36 @@
</svg>
</button>
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
<p class="text-gray-700 text-xl font-semibold">{gettext("Options")}</p>
<div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :show_results, class: "h-4 w-4") %>
<%= label(
{checkbox(f, :show_results, class: "h-4 w-4")}
{label(
f,
:show_results,
gettext("Attendees can see the results on their device"),
class: "text-sm font-medium"
) %>
)}
</div>
<div class="flex gap-x-2 mb-5">
<%= checkbox(f, :multiple, class: "h-4 w-4") %>
<%= label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium") %>
{checkbox(f, :multiple, class: "h-4 w-4")}
{label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium")}
</div>
<div class="flex space-x-3">
<button
type="submit"
phx_disable_with="Loading..."
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
>
<%= case @live_action do
{case @live_action do
:new -> gettext("Create")
:edit -> gettext("Save")
end %>
end}
</button>
<%= if @live_action == :edit do %>
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_target: @myself,
@@ -120,8 +116,8 @@
)
],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
<% end %>
</div>
</.form>

View File

@@ -12,8 +12,6 @@
form={f}
key={:title}
name={gettext("Title")}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
autofocus="true"
required="true"
/>
@@ -33,7 +31,7 @@
if(i == 0, do: "rounded-tl-md")
]}
>
<%= i + 1 %>
{i + 1}
</button>
<% end %>
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() < 10 do %>
@@ -43,7 +41,7 @@
class="text-xs px-3"
phx-target={@myself}
>
+ <%= gettext("Add Question") %>
+ {gettext("Add Question")}
</button>
<% end %>
</div>
@@ -56,8 +54,6 @@
<div class="flex gap-x-3 mt-3 items-center justify-start">
<ClaperWeb.Component.Input.text
form={q}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:content}
name={gettext("Your question")}
autofocus="true"
@@ -67,7 +63,7 @@
<%= if Keyword.has_key?(q.errors, :quiz_question_opts) do %>
<p class="text-supporting-red-500 text-sm my-2">
<%= elem(Keyword.get(q.errors, :quiz_question_opts), 0) %>
{elem(Keyword.get(q.errors, :quiz_question_opts), 0)}
</p>
<% end %>
@@ -78,8 +74,6 @@
<div class="flex-1">
<ClaperWeb.Component.Input.text
form={o}
labelClass={if @dark, do: "text-white"}
fieldClass={if @dark, do: "bg-gray-700 text-white"}
key={:content}
name={gettext("Answer %{index}", index: o.index + 1)}
required="true"
@@ -87,7 +81,7 @@
</div>
<div>
<%= label(class: "mt-6 cursor-pointer flex items-center text-white rounded-md px-2.5 py-2.5 h-full #{if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct), do: "bg-green-500", else: "bg-red-500"}") do %>
<%= checkbox(o, :is_correct, class: "hidden") %>
{checkbox(o, :is_correct, class: "hidden")}
<%= if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct) do %>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -126,7 +120,7 @@
<%= if o.index > 1 do %>
<label>
<div class="cursor-pointer mt-1 text-xs font-semibold text-red-600">
<%= gettext("Delete") %>
{gettext("Delete")}
</div>
<input
type="checkbox"
@@ -149,13 +143,13 @@
phx-target={@myself}
class="mt-5 text-xs text-gray-700"
>
+ <%= gettext("Add answer") %>
+ {gettext("Add answer")}
</button>
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() > 1 do %>
<label phx-click="remove_quiz_question" phx-target={@myself}>
<div class="cursor-pointer mt-4 w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500">
<%= gettext("Remove question") %>
<div class="cursor-pointer mt-4 w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500">
{gettext("Remove question")}
</div>
<input
type="checkbox"
@@ -169,16 +163,16 @@
<% end %>
</div>
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
<p class="text-gray-700 text-xl font-semibold">{gettext("Options")}</p>
<div class="flex gap-x-2 mb-5 mt-3">
<%= checkbox(f, :allow_anonymous, class: "h-4 w-4") %>
<%= label(
{checkbox(f, :allow_anonymous, class: "h-4 w-4")}
{label(
f,
:allow_anonymous,
gettext("Allow anonymous submissions"),
class: "text-sm font-medium"
) %>
)}
</div>
<div class="flex justify-between items-center">
@@ -187,16 +181,16 @@
type="submit"
phx_disable_with="Loading..."
disabled={!@changeset.valid?}
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<%= case @live_action do
{case @live_action do
:new -> gettext("Create")
:edit -> gettext("Save")
end %>
end}
</button>
<%= if @live_action == :edit do %>
<%= link(gettext("Delete"),
{link(gettext("Delete"),
to: "#",
phx_click: "delete",
phx_target: @myself,
@@ -208,14 +202,14 @@
)
],
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
<% end %>
</div>
<%= if @live_action == :edit do %>
<%= link to: ~p"/export/quizzes/#{@quiz.id}/qti", class: "text-xs text-primary-500 font-medium flex items-center gap-1", method: :post, target: "_blank" do %>
<%= gettext("Export to QTI (XML)") %>
{gettext("Export to QTI (XML)")}
<% end %>
<% end %>
</div>

View File

@@ -2,7 +2,7 @@
<div class="border-b border-gray-200 py-4 flex flex-col sm:flex-row sm:items-center justify-between">
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
<%= @page_title %>
{@page_title}
</h1>
</div>
<div class="flex mt-4 space-x-5 sm:mt-0 hidden">
@@ -12,7 +12,7 @@
<div class="mt-12 mb-3">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<%= gettext("Event") %>: <%= @event.name %> (#<%= @event.code %>)
{gettext("Event")}: {@event.name} (#{@event.code})
</h3>
<dl class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
@@ -35,14 +35,14 @@
</svg>
</div>
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
<%= gettext("Audience peak") %>
{gettext("Audience peak")}
</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900">
<%= @event.audience_peak %>
{@event.audience_peak}
<span class="text-xs text-gray-500 font-normal">
<%= ngettext("attendee", "attendees", @event.audience_peak) %>
{ngettext("attendee", "attendees", @event.audience_peak)}
</span>
</p>
</dd>
@@ -67,14 +67,14 @@
</svg>
</div>
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
<%= gettext("Unique attendees") %>
{gettext("Unique attendees")}
</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900">
<%= @distinct_attendee_count %>
{@distinct_attendee_count}
<span class="text-xs text-gray-500 font-normal">
<%= ngettext("attendee", "attendees", @distinct_attendee_count) %>
{ngettext("attendee", "attendees", @distinct_attendee_count)}
</span>
</p>
</dd>
@@ -99,18 +99,18 @@
</svg>
</div>
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
<%= gettext("Messages") %>
{gettext("Messages")}
</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900">
<%= length(@posts) %>
{length(@posts)}
<span class="text-xs text-gray-500 font-normal">
<%= ngettext(
{ngettext(
"from %{count} people",
"from %{count} peoples",
@distinct_poster_count
) %>
)}
</span>
</p>
</dd>
@@ -136,11 +136,11 @@
</svg>
</div>
<p class="ml-16 text-sm font-medium truncate text-gray-500">
<%= gettext("Engagement rate") %>
{gettext("Engagement rate")}
</p>
</dt>
<dd class="ml-16 flex items-baseline flex items-center">
<p class="text-2xl font-semibold text-gray-900"><%= @engagement_rate %>%</p>
<p class="text-2xl font-semibold text-gray-900">{@engagement_rate}%</p>
<a
href="https://docs.claper.co/usage/reports.html#metrics"
target="_blank"
@@ -167,7 +167,7 @@
<div class="pt-5 pb-5">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
<%= gettext("Interactions") %>
{gettext("Interactions")}
</h3>
<div class="border-b border-gray-200">
@@ -177,35 +177,35 @@
phx-value-tab="messages"
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :messages, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
>
<%= gettext("Messages") %>
{gettext("Messages")}
</button>
<button
phx-click="change_tab"
phx-value-tab="polls"
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :polls, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
>
<%= gettext("Polls") %>
{gettext("Polls")}
</button>
<button
phx-click="change_tab"
phx-value-tab="forms"
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :forms, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
>
<%= gettext("Forms") %>
{gettext("Forms")}
</button>
<button
phx-click="change_tab"
phx-value-tab="web_content"
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :web_content, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
>
<%= gettext("Web Content") %>
{gettext("Web Content")}
</button>
<button
phx-click="change_tab"
phx-value-tab="quizzes"
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :quizzes, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
>
<%= gettext("Quizzes") %>
{gettext("Quizzes")}
</button>
</nav>
</div>
@@ -219,7 +219,7 @@
<% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %>
<div class="bg-gray-900 w-full p-6 my-5 text-black shadow-lg rounded-md">
<div class="w-full h-full flex items-center justify-between mb-4">
<p class="text-white text-xl font-semibold"><%= poll.title %></p>
<p class="text-white text-xl font-semibold">{poll.title}</p>
<%= link to: ~p"/export/polls/#{poll.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -233,7 +233,7 @@
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
</svg>
<span><%= gettext("Export") %> (CSV)</span>
<span>{gettext("Export")} (CSV)</span>
<% end %>
</div>
<div>
@@ -252,14 +252,14 @@
>
<div
style={"width: #{percentage}%;"}
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if percentage == "100", do: "rounded-r-lg"}"}
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if percentage == "100", do: "rounded-r-lg"}"}
>
</div>
<div class="flex space-x-3 items-center z-10 text-left">
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
<span class="text-sm z-10">
<%= percentage %>% (<%= opt.vote_count %>)
{percentage}% ({opt.vote_count})
</span>
</div>
<% end %>
@@ -269,7 +269,7 @@
</div>
<% end %>
<% else %>
<p class="italic text-gray-500"><%= gettext("No poll has been created") %></p>
<p class="italic text-gray-500">{gettext("No poll has been created")}</p>
<% end %>
</div>
<% :forms -> %>
@@ -278,7 +278,7 @@
<%= for form <- @event.presentation_file.forms do %>
<div class="flex justify-between items-center mb-5">
<span class="text-xl font-semibold text-gray-900">
<%= form.title %>
{form.title}
</span>
<%= if length(form.form_submits) > 0 do %>
@@ -295,14 +295,14 @@
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
</svg>
<span><%= gettext("Export") %> (CSV)</span>
<span>{gettext("Export")} (CSV)</span>
<% end %>
<% end %>
</div>
<%= if length(form.form_submits) == 0 do %>
<p class="italic text-gray-500">
<%= gettext("No form submission has been sent") %>
{gettext("No form submission has been sent")}
</p>
<% end %>
@@ -325,7 +325,7 @@
<div>
<%= for res <- fs.response do %>
<p><strong><%= elem(res, 0) %>:</strong> <%= elem(res, 1) %></p>
<p><strong>{elem(res, 0)}:</strong> {elem(res, 1)}</p>
<% end %>
</div>
</div>
@@ -335,7 +335,7 @@
</div>
<% end %>
<% else %>
<p class="italic text-gray-500"><%= gettext("No form has been created") %></p>
<p class="italic text-gray-500">{gettext("No form has been created")}</p>
<% end %>
</div>
<% :web_content -> %>
@@ -343,7 +343,7 @@
<%= if length(@event.presentation_file.embeds) > 0 do %>
<%= for embed <- @event.presentation_file.embeds do %>
<span class="text-xl font-semibold text-gray-900 mb-4">
<%= embed.title %>
{embed.title}
</span>
<div class="text-black break-all mt-4 mb-10">
<.live_component
@@ -356,7 +356,7 @@
<% end %>
<% else %>
<p class="italic text-gray-500">
<%= gettext("No web content has been created") %>
{gettext("No web content has been created")}
</p>
<% end %>
</div>
@@ -377,13 +377,13 @@
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
</svg>
<span><%= gettext("Export") %> (CSV)</span>
<span>{gettext("Export")} (CSV)</span>
<% end %>
</div>
<% end %>
<%= if length(@posts) == 0 do %>
<p class="italic text-gray-500"><%= gettext("No messages has been sent") %></p>
<p class="italic text-gray-500">{gettext("No messages has been sent")}</p>
<% end %>
<div>
@@ -405,7 +405,7 @@
<div class="bg-gray-900 w-full p-4 text-black shadow-lg rounded-md mb-10">
<div class="mb-4">
<div class="w-full flex items-center justify-between">
<p class="text-white text-xl font-semibold mb-2"><%= quiz.title %></p>
<p class="text-white text-xl font-semibold mb-2">{quiz.title}</p>
<%= link to: ~p"/export/quizzes/#{quiz.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -419,22 +419,22 @@
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
</svg>
<span><%= gettext("Export") %> (CSV)</span>
<span>{gettext("Export")} (CSV)</span>
<% end %>
</div>
<p class="text-gray-400 text-sm">
<%= gettext("Average score") %>:
{gettext("Average score")}:
<span class="font-semibold">
<%= Claper.Quizzes.calculate_average_score(quiz.id) %>/<%= length(
{Claper.Quizzes.calculate_average_score(quiz.id)}/{length(
quiz.quiz_questions
) %>
)}
</span>
</p>
<p class="text-gray-400 text-sm">
<%= gettext("Total submissions") %>:
{gettext("Total submissions")}:
<span class="font-semibold">
<%= Claper.Quizzes.get_submission_count(quiz.id) %>
{Claper.Quizzes.get_submission_count(quiz.id)}
</span>
</p>
</div>
@@ -443,7 +443,7 @@
<%= for {question, _idx} <- Enum.with_index(quiz.quiz_questions) do %>
<div class="border-t border-gray-700 pt-4 mt-4 first:border-t-0 first:pt-0 first:mt-0">
<p class="text-white text-lg font-medium mb-3">
<%= question.content %>
{question.content}
</p>
<div class="space-y-2">
<%= for opt <- question.quiz_question_opts do %>
@@ -457,10 +457,10 @@
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
</div>
<% end %>
<span class="flex-1 pr-2"><%= opt.content %></span>
<span class="flex-1 pr-2">{opt.content}</span>
</div>
<span class="text-sm">
<%= opt.percentage %>% (<%= opt.response_count %>)
{opt.percentage}% ({opt.response_count})
</span>
</div>
</div>
@@ -473,7 +473,7 @@
<% end %>
</div>
<% else %>
<p class="italic text-gray-500"><%= gettext("No quiz has been created") %></p>
<p class="italic text-gray-500">{gettext("No quiz has been created")}</p>
<% end %>
</div>
<% end %>

View File

@@ -2,7 +2,7 @@
<div class="border-b border-gray-200 py-4 sm:flex sm:items-center sm:justify-between">
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
<%= gettext("My account") %>
{gettext("My account")}
</h1>
</div>
<div class="mt-4 flex sm:mt-0 sm:ml-4"></div>
@@ -26,7 +26,7 @@
id="update_email"
class="mt-5 md:flex md:items-end"
>
<%= hidden_input(f, :action, name: "action", value: "update_email") %>
{hidden_input(f, :action, name: "action", value: "update_email")}
<ClaperWeb.Component.Input.email
form={f}
@@ -35,11 +35,11 @@
required="true"
/>
<%= submit(gettext("Save"),
{submit(gettext("Save"),
phx_disable_with: "Saving...",
class:
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
) %>
)}
</.form>
</div>
</.live_component>
@@ -62,7 +62,7 @@
id="update_password"
class="mt-5 md:flex md:items-end gap-x-2"
>
<%= hidden_input(f, :action, name: "action", value: "update_password") %>
{hidden_input(f, :action, name: "action", value: "update_password")}
<ClaperWeb.Component.Input.password
form={f}
@@ -77,11 +77,11 @@
required="true"
/>
<%= submit(gettext("Save"),
{submit(gettext("Save"),
phx_disable_with: "Saving...",
class:
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
) %>
)}
</.form>
</div>
</.live_component>
@@ -104,7 +104,7 @@
id="set_password"
class="mt-5 md:flex md:items-end gap-x-2"
>
<%= hidden_input(f, :action, name: "action", value: "set_password") %>
{hidden_input(f, :action, name: "action", value: "set_password")}
<ClaperWeb.Component.Input.password
form={f}
@@ -120,67 +120,67 @@
required="true"
/>
<%= submit(gettext("Save"),
{submit(gettext("Save"),
phx_disable_with: "Saving...",
class:
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
) %>
)}
</.form>
</div>
</.live_component>
<% end %>
<div class="shadow overflow-hidden sm:rounded-lg">
<div class="overflow-hidden sm:rounded-lg">
<div class="py-5">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<%= gettext("Personal informations") %>
{gettext("Personal informations")}
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
<%= gettext("Your personal informations to access your account") %>
{gettext("Your personal informations to access your account")}
</p>
</div>
<div class="border-t border-gray-200 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-200">
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-2 sm:gap-4">
<dt class="text-sm font-medium text-gray-500">
<%= gettext("Email address") %>
{gettext("Email address")}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="flex-grow"><%= @current_user.email %></span>
<span :if={!@is_external_user} class="ml-4 flex-shrink-0">
<span class="grow">{@current_user.email}</span>
<span :if={!@is_external_user} class="ml-4 shrink-0">
<.link
patch={~p"/users/settings/edit/email"}
class="rounded-md font-medium text-purple-600 hover:text-purple-500"
>
<%= gettext("Change") %>
{gettext("Change")}
</.link>
</span>
</dd>
<dt :if={!@is_external_user} class="text-sm font-medium text-gray-500">
<%= gettext("Password") %>
{gettext("Password")}
</dt>
<dd :if={!@is_external_user} class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="flex-grow">********</span>
<span class="ml-4 flex-shrink-0">
<span class="grow">********</span>
<span class="ml-4 shrink-0">
<.link
patch={~p"/users/settings/edit/password"}
class="rounded-md font-medium text-purple-600 hover:text-purple-500"
>
<%= gettext("Change") %>
{gettext("Change")}
</.link>
</span>
</dd>
<dt :if={@is_external_user} class="text-sm font-medium text-gray-500">
<%= gettext("Accounts linked") %>
{gettext("Accounts linked")}
</dt>
<dd class="text-sm text-gray-900 sm:col-span-2">
<%= for account <- @oidc_accounts do %>
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mt-2 sm:mt-0 mb-2">
<img src="/images/icons/openid.png" class="w-5" />
<span class="flex-grow flex items-center gap-x-2">
<span><%= account.provider %></span>
<span class="grow flex items-center gap-x-2">
<span>{account.provider}</span>
<div
:if={account.organization}
class="text-gray-500 text-xs flex items-center gap-x-1"
@@ -198,7 +198,7 @@
/>
</svg>
<span><%= account.organization %></span>
<span>{account.organization}</span>
</div>
</span>
<span :if={@allow_unlink_external_provider}>
@@ -208,7 +208,7 @@
data-confirm={gettext("Are you sure you want to unlink this account?")}
class="font-medium text-red-600 hover:text-red-500"
>
<%= gettext("Unlink") %>
{gettext("Unlink")}
</button>
</span>
</div>
@@ -216,17 +216,17 @@
<%= for account <- @lti_accounts do %>
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mb-2">
<img src="/images/icons/lms.png" class="w-8" />
<span class="flex-grow">
LMS <span class="text-gray-500 text-xs">#<%= account.registration_id %></span>
<span class="grow">
LMS <span class="text-gray-500 text-xs">#{account.registration_id}</span>
</span>
<span :if={@allow_unlink_external_provider} class="ml-4 flex-shrink-0">
<span :if={@allow_unlink_external_provider} class="ml-4 shrink-0">
<button
phx-click="unlink"
phx-value-registration_id={account.registration_id}
data-confirm={gettext("Are you sure you want to unlink this account?")}
class="font-medium text-red-600 hover:text-red-500"
>
<%= gettext("Unlink") %>
{gettext("Unlink")}
</button>
</span>
</div>
@@ -238,29 +238,42 @@
<div>
<div class="py-5">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<%= gettext("Preferences") %>
{gettext("Preferences")}
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
<%= gettext("Customize your account") %>
{gettext("Customize your account")}
</p>
</div>
<div class="border-t border-gray-200 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-200">
<div class="mt-5">
<.form :let={f} for={@preferences_changeset} phx-change="save">
<%= hidden_input(f, :action, name: "action", value: "update_preferences") %>
{hidden_input(f, :action, name: "action", value: "update_preferences")}
<ClaperWeb.Component.Input.select
form={f}
fieldClass="!w-auto"
fieldClass="w-auto! bg-white"
labelClass="text-sm font-medium text-gray-500"
array={[
{"Deutsch", "de"},
{"English", "en"},
{"Español", "es"},
{"Français", "fr"},
{"Italiano", "it"},
{"Nederlands", "nl"}
]}
array={
[
{"Deutsch", "de"},
{"English", "en"},
{"Español", "es"},
{"Français", "fr"},
{"Hungarian", "hu"},
{"Italiano", "it"},
{"Latvian", "lv"},
{"Nederlands", "nl"}
]
|> Enum.filter(fn {_name, code} ->
code in Application.get_env(:claper, :languages, [
"en",
"fr",
"es",
"it",
"de"
])
end)
}
key={:locale}
name={gettext("Language")}
/>
@@ -272,23 +285,23 @@
<div :if={!@is_external_user}>
<div class="py-5">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<%= gettext("Danger zone") %>
{gettext("Danger zone")}
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
<%= gettext("Be careful, these actions are irreversible") %>
{gettext("Be careful, these actions are irreversible")}
</p>
</div>
<div class="border-t border-gray-200 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-200">
<div class="my-5">
<%= link(gettext("Delete account"),
{link(gettext("Delete account"),
to: ~p"/users/register/delete",
method: :delete,
"data-confirm":
gettext("All your events and files will be permanently deleted, are you sure?"),
class:
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
) %>
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
)}
</div>
</dl>
</div>

Some files were not shown because too many files have changed in this diff Show More