mirror of
https://github.com/fergalmoran/malarkey.git
synced 2025-12-22 09:48:46 +00:00
Compare commits
6 Commits
3d46990b9c
...
18fe2cecd1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18fe2cecd1 | ||
|
|
4590c492cb | ||
|
|
01af5992be | ||
|
|
9e2c740041 | ||
|
|
21aee5be2c | ||
|
|
d780d59442 |
@@ -1,47 +0,0 @@
|
||||
# This file excludes paths from the Docker build context.
|
||||
#
|
||||
# By default, Docker's build context includes all files (and folders) in the
|
||||
# current directory. Even if a file isn't copied into the container it is still sent to
|
||||
# the Docker daemon.
|
||||
#
|
||||
# There are multiple reasons to exclude files from the build context:
|
||||
#
|
||||
# 1. Prevent nested folders from being copied into the container (ex: exclude
|
||||
# /assets/node_modules when copying /assets)
|
||||
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
|
||||
# 3. Avoid sending files containing sensitive information
|
||||
#
|
||||
# More information on using .dockerignore is available here:
|
||||
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
|
||||
#
|
||||
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
|
||||
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
|
||||
.git
|
||||
!.git/HEAD
|
||||
!.git/refs
|
||||
|
||||
# Common development/test artifacts
|
||||
/cover/
|
||||
/doc/
|
||||
/test/
|
||||
/tmp/
|
||||
.elixir_ls
|
||||
|
||||
# Mix artifacts
|
||||
/_build/
|
||||
/deps/
|
||||
*.ez
|
||||
|
||||
# Generated on crash by the VM
|
||||
erl_crash.dump
|
||||
|
||||
# Static artifacts - These should be fetched and built inside the Docker image
|
||||
/assets/node_modules/
|
||||
/priv/static/assets/
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
.prv/
|
||||
11
.env
Normal file
11
.env
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
# Giphy API Configuration
|
||||
GIPHY_API_KEY=iMV61T4eUDWLTvEon2kgGAqkK9LTYmOY
|
||||
|
||||
# OAuth Configuration (existing)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
# Giphy API Configuration
|
||||
GIPHY_API_KEY=your_giphy_api_key_here
|
||||
|
||||
# OAuth Configuration (existing)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
15
.github/workflows/fly.yml
vendored
15
.github/workflows/fly.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Fly Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
- run: flyctl deploy --remote-only
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -19,6 +19,9 @@ erl_crash.dump
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
malarkey-*.tar
|
||||
|
||||
@@ -32,5 +35,4 @@ malarkey-*.tar
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
|
||||
.prv/
|
||||
priv/static/uploads/
|
||||
5
.idea/.gitignore
generated
vendored
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
12
.idea/malarkey.iml
generated
12
.idea/malarkey.iml
generated
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/malarkey.iml" filepath="$PROJECT_DIR$/.idea/malarkey.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/vcs.xml
generated
7
.idea/vcs.xml
generated
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/deps/pow" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
22
.vscode/settings.json
vendored
22
.vscode/settings.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#e76105",
|
||||
"activityBar.background": "#e76105",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#025e27",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#e76105",
|
||||
"statusBar.background": "#b54c04",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#e76105",
|
||||
"statusBarItem.remoteBackground": "#b54c04",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#b54c04",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#b54c0499",
|
||||
"titleBar.inactiveForeground": "#e7e7e799"
|
||||
},
|
||||
"peacock.color": "#b54c04"
|
||||
}
|
||||
99
Dockerfile
99
Dockerfile
@@ -1,99 +0,0 @@
|
||||
# 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-20221004-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.14.0-erlang-25.1.2-debian-bullseye-20221004-slim
|
||||
#
|
||||
ARG ELIXIR_VERSION=1.14.0
|
||||
ARG OTP_VERSION=25.1.2
|
||||
ARG DEBIAN_VERSION=bullseye-20221004-slim
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
||||
# set runner ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/malarkey ./
|
||||
|
||||
USER nobody
|
||||
|
||||
CMD ["/app/bin/server"]
|
||||
|
||||
# Appended by flyctl
|
||||
ENV ECTO_IPV6 true
|
||||
ENV ERL_AFLAGS "-proto_dist inet6_tcp"
|
||||
|
||||
# Appended by flyctl
|
||||
ENV ECTO_IPV6 true
|
||||
ENV ERL_AFLAGS "-proto_dist inet6_tcp"
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Fergal Moran
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
121
README.md
121
README.md
@@ -1,2 +1,119 @@
|
||||
# malarkey
|
||||
Meaningless talk & nonsense
|
||||
# Malarkey - Twitter Clone with Phoenix LiveView
|
||||
|
||||
A full-featured Twitter clone built with Elixir, Phoenix LiveView, and PostgreSQL with OAuth authentication.
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
✅ Email/Password registration and login
|
||||
✅ OAuth providers (Google, GitHub, Twitter)
|
||||
✅ Password reset functionality
|
||||
✅ Email confirmation
|
||||
✅ Session management with "Remember me" option
|
||||
✅ Secure password hashing with Pbkdf2
|
||||
✅ User settings management
|
||||
|
||||
### Social Features
|
||||
✅ Post creation (280 character limit with character counter)
|
||||
✅ Reposts and Quote Posts
|
||||
✅ Replies/Comments (threaded conversations)
|
||||
✅ Like/Unlike posts
|
||||
✅ Follow/Unfollow users
|
||||
✅ User profiles with bio, location, website
|
||||
✅ Profile and header images
|
||||
✅ Real-time updates via Phoenix PubSub
|
||||
|
||||
### User Interface
|
||||
✅ Home timeline feed
|
||||
✅ Post detail pages with replies
|
||||
✅ User profile pages (posts, replies, media, likes tabs)
|
||||
✅ Followers/Following lists
|
||||
✅ Notifications page
|
||||
✅ Explore/Search page
|
||||
✅ Responsive design with Tailwind CSS
|
||||
✅ Real-time post composer with character counter
|
||||
✅ Interactive post actions (reply, repost, like, share)
|
||||
|
||||
### Coming Soon
|
||||
🚧 Media upload (images, videos, GIFs)
|
||||
🚧 Direct messaging
|
||||
🚧 Hashtags and trending topics
|
||||
🚧 User mentions (@username)
|
||||
🚧 Bookmarks
|
||||
🚧 Lists
|
||||
🚧 Advanced search
|
||||
🚧 Dark mode toggle
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
- Erlang/OTP 28+
|
||||
- Elixir 1.19+
|
||||
- PostgreSQL 12+
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
mix deps.get
|
||||
```
|
||||
|
||||
2. Start PostgreSQL and create the database:
|
||||
```bash
|
||||
sudo systemctl start postgresql # For systemd-based systems
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
```
|
||||
|
||||
3. (Optional) Set up OAuth provider credentials:
|
||||
```bash
|
||||
export GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
export GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
export GITHUB_CLIENT_ID="your_github_client_id"
|
||||
export GITHUB_CLIENT_SECRET="your_github_client_secret"
|
||||
export TWITTER_CONSUMER_KEY="your_twitter_consumer_key"
|
||||
export TWITTER_CONSUMER_SECRET="your_twitter_consumer_secret"
|
||||
```
|
||||
|
||||
4. Start the Phoenix server:
|
||||
```bash
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
Visit [`localhost:4000`](http://localhost:4000) in your browser.
|
||||
|
||||
## Database Schema
|
||||
|
||||
- **Users**: Authentication, profiles, statistics
|
||||
- **Posts**: Content, media, relationships (replies, reposts, quotes)
|
||||
- **Likes**: User-post favorites
|
||||
- **Follows**: User relationships
|
||||
- **OAuth Identities**: Third-party authentication
|
||||
- **User Tokens**: Sessions, confirmations, password resets
|
||||
|
||||
## Architecture
|
||||
|
||||
Built with Phoenix best practices:
|
||||
- Context-driven design (Accounts, Social)
|
||||
- Ecto migrations with proper constraints
|
||||
- UUID primary keys
|
||||
- Counter caches for performance
|
||||
- Real-time updates with PubSub
|
||||
- Secure authentication with Ueberauth
|
||||
|
||||
## Next Steps
|
||||
|
||||
Complete the LiveView components:
|
||||
- User registration/login/settings LiveViews
|
||||
- Timeline with post composer
|
||||
- Profile pages with tabs
|
||||
- Post detail views with replies
|
||||
- Implement Tailwind CSS styling with shadcn-inspired components
|
||||
|
||||
## Learn more
|
||||
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
|
||||
@@ -2,4 +2,75 @@
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
/* Shadcn-inspired CSS variables */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 263.4 70% 50.4%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 263.4 70% 50.4%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Post animations */
|
||||
@keyframes slideInFromTop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideInFromTop 0.3s ease-out;
|
||||
}
|
||||
|
||||
158
assets/js/app.js
158
assets/js/app.js
@@ -22,13 +22,165 @@ import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
|
||||
// Theme handling
|
||||
const applyTheme = (theme) => {
|
||||
if (theme === 'system') {
|
||||
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
document.documentElement.classList.toggle('dark', isDarkMode)
|
||||
} else if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('[data-theme]').forEach(btn => {
|
||||
if (btn.dataset.theme === theme) {
|
||||
btn.classList.remove('border-input')
|
||||
btn.classList.add('border-primary', 'bg-accent')
|
||||
} else {
|
||||
btn.classList.remove('border-primary', 'bg-accent')
|
||||
btn.classList.add('border-input')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const initTheme = () => {
|
||||
const theme = localStorage.getItem('theme') || 'system'
|
||||
applyTheme(theme)
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (localStorage.getItem('theme') === 'system') {
|
||||
document.documentElement.classList.toggle('dark', e.matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize theme on page load
|
||||
initTheme()
|
||||
|
||||
// Hooks for theme management and media upload
|
||||
let Hooks = {}
|
||||
Hooks.ThemeSelector = {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", () => {
|
||||
const theme = this.el.dataset.theme
|
||||
localStorage.setItem('theme', theme)
|
||||
applyTheme(theme)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Hooks.PasteImage = {
|
||||
mounted() {
|
||||
console.log("PasteImage hook mounted on element:", this.el.id)
|
||||
|
||||
// Auto-focus if this is a reply textarea
|
||||
if (this.el.id === "reply-textarea") {
|
||||
this.el.focus()
|
||||
}
|
||||
|
||||
this.el.addEventListener("paste", (e) => {
|
||||
console.log("Paste event detected on textarea:", this.el.id)
|
||||
|
||||
const clipboardData = e.clipboardData || e.originalEvent?.clipboardData || window.clipboardData
|
||||
|
||||
if (!clipboardData) {
|
||||
console.log("No clipboard data available")
|
||||
return
|
||||
}
|
||||
|
||||
const items = clipboardData.items
|
||||
if (!items) {
|
||||
console.log("No clipboard items")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Clipboard items:", items.length)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
console.log("Item type:", item.type, item.kind)
|
||||
|
||||
if (item.kind === 'file' && item.type.indexOf("image") !== -1) {
|
||||
e.preventDefault()
|
||||
console.log("Image found in clipboard")
|
||||
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) {
|
||||
console.log("Failed to get file from clipboard item")
|
||||
continue
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (event) => {
|
||||
console.log("Image loaded, sending to server from textarea:", this.el.id)
|
||||
this.pushEvent("paste_image", {
|
||||
data: event.target.result,
|
||||
type: blob.type,
|
||||
textarea_id: this.el.id
|
||||
})
|
||||
}
|
||||
|
||||
reader.onerror = (error) => {
|
||||
console.error("FileReader error:", error)
|
||||
}
|
||||
|
||||
reader.readAsDataURL(blob)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Hooks.PostCard = {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", (e) => {
|
||||
// Check if click is on a link, button, or inside an action area
|
||||
const isInteractiveElement = e.target.closest('a, button')
|
||||
|
||||
if (!isInteractiveElement) {
|
||||
const postUrl = this.el.dataset.postUrl
|
||||
if (postUrl) {
|
||||
this.pushEvent("navigate", {url: postUrl})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
// Handle delete-post animation
|
||||
window.addEventListener("phx:delete-post", (e) => {
|
||||
const postElement = document.getElementById(e.detail.id)
|
||||
if (postElement) {
|
||||
// Add fade-out classes
|
||||
postElement.classList.add('opacity-0', 'scale-95', 'transition-all', 'duration-300')
|
||||
// The actual removal happens via stream_delete after the animation
|
||||
}
|
||||
})
|
||||
|
||||
// Stop propagation for nested links in post cards
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.closest('[data-post-link]')
|
||||
if (target) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}, true)
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
|
||||
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
6
assets/package-lock.json
generated
Normal file
6
assets/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "assets",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -2,25 +2,118 @@
|
||||
// https://tailwindcss.com/docs/configuration
|
||||
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./js/**/*.js",
|
||||
"../lib/*_web.ex",
|
||||
"../lib/*_web/**/*.*ex"
|
||||
"../lib/malarkey_web.ex",
|
||||
"../lib/malarkey_web/**/*.*ex"
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#FD4F00",
|
||||
}
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
|
||||
// Allows prefixing tailwind classes with LiveView classes to add rules
|
||||
// only when LiveView classes are applied, for example:
|
||||
//
|
||||
// <div class="phx-click-loading:animate-ping">
|
||||
//
|
||||
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
|
||||
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
|
||||
plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
|
||||
plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
|
||||
|
||||
// Embeds Heroicons (https://heroicons.com) into your app.css bundle
|
||||
// See your `CoreComponents.icon/1` for more information.
|
||||
//
|
||||
plugin(function({matchComponents, theme}) {
|
||||
let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
|
||||
let values = {}
|
||||
let icons = [
|
||||
["", "/24/outline"],
|
||||
["-solid", "/24/solid"],
|
||||
["-mini", "/20/solid"],
|
||||
["-micro", "/16/solid"]
|
||||
]
|
||||
icons.forEach(([suffix, dir]) => {
|
||||
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||
let name = path.basename(file, ".svg") + suffix
|
||||
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||
})
|
||||
})
|
||||
matchComponents({
|
||||
"hero": ({name, fullPath}) => {
|
||||
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||
let size = theme("spacing.6")
|
||||
if (name.endsWith("-mini")) {
|
||||
size = theme("spacing.5")
|
||||
} else if (name.endsWith("-micro")) {
|
||||
size = theme("spacing.4")
|
||||
}
|
||||
return {
|
||||
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||
"-webkit-mask": `var(--hero-${name})`,
|
||||
"mask": `var(--hero-${name})`,
|
||||
"mask-repeat": "no-repeat",
|
||||
"background-color": "currentColor",
|
||||
"vertical-align": "middle",
|
||||
"display": "inline-block",
|
||||
"width": size,
|
||||
"height": size
|
||||
}
|
||||
}
|
||||
}, {values})
|
||||
})
|
||||
]
|
||||
}
|
||||
44
assets/vendor/topbar.js
vendored
44
assets/vendor/topbar.js
vendored
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* @license MIT
|
||||
* topbar 1.0.0, 2021-01-06
|
||||
* Modifications:
|
||||
* - add delayedShow(time) (2022-09-21)
|
||||
* http://buunguyen.github.io/topbar
|
||||
* topbar 2.0.0, 2023-02-04
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
@@ -98,26 +96,26 @@
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
delayedShow: function(time) {
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), time);
|
||||
},
|
||||
show: function () {
|
||||
if (showing) return;
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
|
||||
@@ -8,18 +8,19 @@
|
||||
import Config
|
||||
|
||||
config :malarkey,
|
||||
ecto_repos: [Malarkey.Repo]
|
||||
ecto_repos: [Malarkey.Repo],
|
||||
generators: [timestamp_type: :utc_datetime]
|
||||
|
||||
# Configures the endpoint
|
||||
config :malarkey, MalarkeyWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
render_errors: [
|
||||
formats: [html: MalarkeyWeb.ErrorHTML, json: MalarkeyWeb.ErrorJSON],
|
||||
layout: false
|
||||
],
|
||||
pubsub_server: Malarkey.PubSub,
|
||||
live_view: [signing_salt: "UYdloyCB"],
|
||||
check_origin: ["https://malarkey.fergl.ie/"]
|
||||
live_view: [signing_salt: "SH8++5n5"]
|
||||
|
||||
# Configures the mailer
|
||||
#
|
||||
@@ -32,8 +33,8 @@ config :malarkey, Malarkey.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.14.41",
|
||||
default: [
|
||||
version: "0.17.11",
|
||||
malarkey: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
@@ -42,8 +43,8 @@ config :esbuild,
|
||||
|
||||
# Configure tailwind (the version is required)
|
||||
config :tailwind,
|
||||
version: "3.1.8",
|
||||
default: [
|
||||
version: "3.4.3",
|
||||
malarkey: [
|
||||
args: ~w(
|
||||
--config=tailwind.config.js
|
||||
--input=css/app.css
|
||||
@@ -60,6 +61,38 @@ config :logger, :console,
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Configure Ueberauth for OAuth providers
|
||||
config :ueberauth, Ueberauth,
|
||||
providers: [
|
||||
google: {Ueberauth.Strategy.Google, []},
|
||||
github: {Ueberauth.Strategy.Github, []},
|
||||
twitter: {Ueberauth.Strategy.Twitter, []},
|
||||
identity: {Ueberauth.Strategy.Identity, [
|
||||
callback_methods: ["POST"],
|
||||
uid_field: :email,
|
||||
nickname_field: :email
|
||||
]}
|
||||
]
|
||||
|
||||
# Configure OAuth provider credentials (move to runtime.exs for production)
|
||||
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
|
||||
client_id: System.get_env("GOOGLE_CLIENT_ID"),
|
||||
client_secret: System.get_env("GOOGLE_CLIENT_SECRET")
|
||||
|
||||
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
|
||||
client_id: System.get_env("GITHUB_CLIENT_ID"),
|
||||
client_secret: System.get_env("GITHUB_CLIENT_SECRET")
|
||||
|
||||
config :ueberauth, Ueberauth.Strategy.Twitter.OAuth,
|
||||
consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
|
||||
consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
|
||||
|
||||
|
||||
# Configure local media upload directory and Giphy API
|
||||
config :malarkey,
|
||||
media_upload_dir: "priv/static/uploads",
|
||||
giphy_api_key: System.get_env("GIPHY_API_KEY") || ""
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
||||
|
||||
@@ -14,8 +14,8 @@ config :malarkey, Malarkey.Repo,
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we use it
|
||||
# with esbuild to bundle .js and .css sources.
|
||||
# watchers to your application. For example, we can use it
|
||||
# to bundle .js and .css sources.
|
||||
config :malarkey, MalarkeyWeb.Endpoint,
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||
@@ -23,10 +23,10 @@ config :malarkey, MalarkeyWeb.Endpoint,
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: true,
|
||||
secret_key_base: "7J/3rVbYDSqC9cp4x/IxzhIr3OBHaQXso8CbFf0j4GfyfwCIpvc5ghgO7vPuBCwP",
|
||||
secret_key_base: "VAjY0tHhesCtCCj8LPSuVu9hdCofA63uyNJT6PAwcGHxlHYTUMDax203Bbm7YD0o",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
|
||||
esbuild: {Esbuild, :install_and_run, [:malarkey, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:malarkey, ~w(--watch)]}
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
@@ -56,10 +56,9 @@ config :malarkey, MalarkeyWeb.Endpoint,
|
||||
config :malarkey, MalarkeyWeb.Endpoint,
|
||||
live_reload: [
|
||||
patterns: [
|
||||
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/malarkey_web/(live|views)/.*(ex)$",
|
||||
~r"lib/malarkey_web/templates/.*(eex)$"
|
||||
~r"lib/malarkey_web/(controllers|live|components)/.*(ex|heex)$"
|
||||
]
|
||||
]
|
||||
|
||||
@@ -76,5 +75,11 @@ config :phoenix, :stacktrace_depth, 20
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Include HEEx debug annotations as HTML comments in rendered markup
|
||||
debug_heex_annotations: true,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import Config
|
||||
|
||||
# For production, don't forget to configure the url host
|
||||
# to something meaningful, Phoenix uses this information
|
||||
# when generating URLs.
|
||||
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix phx.digest` task,
|
||||
# manifest is generated by the `mix assets.deploy` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :malarkey, MalarkeyWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, :api_client, Malarkey.Finch
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Malarkey.Finch
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
@@ -28,7 +28,7 @@ if config_env() == :prod do
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
config :malarkey, Malarkey.Repo,
|
||||
# ssl: true,
|
||||
@@ -51,12 +51,14 @@ if config_env() == :prod do
|
||||
host = System.get_env("PHX_HOST") || "example.com"
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :malarkey, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
config :malarkey, MalarkeyWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
@@ -87,8 +89,8 @@ if config_env() == :prod do
|
||||
# "priv/ssl/server.key". For all supported SSL configuration
|
||||
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||
#
|
||||
# We also recommend setting `force_ssl` in your endpoint, ensuring
|
||||
# no data is ever sent via http, always redirecting to https:
|
||||
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||
# ensuring no data is ever sent via http, always redirecting to https:
|
||||
#
|
||||
# config :malarkey, MalarkeyWeb.Endpoint,
|
||||
# force_ssl: [hsts: true]
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :bcrypt_elixir, :log_rounds, 1
|
||||
|
||||
# Configure your database
|
||||
#
|
||||
# The MIX_TEST_PARTITION environment variable can be used
|
||||
@@ -14,19 +11,19 @@ config :malarkey, Malarkey.Repo,
|
||||
hostname: "localhost",
|
||||
database: "malarkey_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: 10
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :malarkey, MalarkeyWeb.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||
secret_key_base: "2/EjqngGC/YAYuEnyFk4BIRyMhBs29VtzndNLvvdYeiegOFctblOTc4i5m8z4GvS",
|
||||
secret_key_base: "sXXdDtdo1Pux/ImB+2oaTw2XwqA0vLUnPs6Pf86i0VmYRAR518hQJ5tktY7DEn6+",
|
||||
server: false
|
||||
|
||||
# In test we don't send emails.
|
||||
# In test we don't send emails
|
||||
config :malarkey, Malarkey.Mailer, adapter: Swoosh.Adapters.Test
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
# Disable swoosh api client as it is only required for production adapters
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
@@ -34,3 +31,7 @@ config :logger, level: :warning
|
||||
|
||||
# Initialize plugs at runtime for faster test compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
config :phoenix_live_view,
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
43
fly.toml
43
fly.toml
@@ -1,43 +0,0 @@
|
||||
# fly.toml file generated for malarkey on 2022-11-25T18:51:01Z
|
||||
|
||||
app = "malarkey"
|
||||
kill_signal = "SIGTERM"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[deploy]
|
||||
release_command = "/app/bin/migrate"
|
||||
|
||||
[env]
|
||||
PHX_HOST = "malarkey.fly.dev"
|
||||
PORT = "8080"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 8080
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
force_https = true
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 0
|
||||
timeout = "2s"
|
||||
@@ -6,7 +6,7 @@ defmodule Malarkey.Accounts do
|
||||
import Ecto.Query, warn: false
|
||||
alias Malarkey.Repo
|
||||
|
||||
alias Malarkey.Accounts.{User, UserToken, UserNotifier}
|
||||
alias Malarkey.Accounts.{User, UserToken, OAuthIdentity}
|
||||
|
||||
## Database getters
|
||||
|
||||
@@ -58,7 +58,23 @@ defmodule Malarkey.Accounts do
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
def get_user!(id) do
|
||||
Repo.get!(User, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by username.
|
||||
"""
|
||||
def get_user_by_username(username) when is_binary(username) do
|
||||
Repo.get_by(User, username: username)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by username with preloaded associations.
|
||||
"""
|
||||
def get_user_by_username_with_stats(username) when is_binary(username) do
|
||||
Repo.get_by(User, username: username)
|
||||
end
|
||||
|
||||
## User registration
|
||||
|
||||
@@ -138,8 +154,8 @@ defmodule Malarkey.Accounts do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
%User{} = user <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, user.email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
@@ -157,12 +173,12 @@ defmodule Malarkey.Accounts do
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
@doc """
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})")
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/\#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
@@ -171,7 +187,8 @@ defmodule Malarkey.Accounts do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
# UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
{:ok, %{to: user.email, body: "Update email token: #{encoded_token}"}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -215,6 +232,31 @@ defmodule Malarkey.Accounts do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user profile.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_profile(user, %{display_name: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_profile(user, %{display_name: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_profile(%User{} = user, attrs) do
|
||||
user
|
||||
|> User.profile_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user profile.
|
||||
"""
|
||||
def change_user_profile(%User{} = user, attrs \\ %{}) do
|
||||
User.profile_changeset(user, attrs)
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
@@ -244,15 +286,15 @@ defmodule Malarkey.Accounts do
|
||||
|
||||
## Confirmation
|
||||
|
||||
@doc ~S"""
|
||||
@doc """
|
||||
Delivers the confirmation email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
|
||||
iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/\#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/\#{&1}"))
|
||||
{:error, :already_confirmed}
|
||||
|
||||
"""
|
||||
@@ -263,7 +305,8 @@ defmodule Malarkey.Accounts do
|
||||
else
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
# UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
{:ok, %{to: user.email, body: "Confirmation token: #{encoded_token}"}}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -291,12 +334,12 @@ defmodule Malarkey.Accounts do
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc ~S"""
|
||||
@doc """
|
||||
Delivers the reset password email to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
|
||||
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
@@ -304,7 +347,8 @@ defmodule Malarkey.Accounts do
|
||||
when is_function(reset_password_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
# UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
{:ok, %{to: user.email, body: "Reset token: #{encoded_token}"}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -350,4 +394,81 @@ defmodule Malarkey.Accounts do
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## OAuth
|
||||
|
||||
@doc """
|
||||
Gets or creates a user from OAuth provider data.
|
||||
"""
|
||||
def get_or_create_oauth_user(provider, auth) do
|
||||
provider = to_string(provider)
|
||||
uid = auth.uid
|
||||
email = auth.info.email
|
||||
username = generate_username(auth.info.nickname || auth.info.name || email)
|
||||
|
||||
case Repo.get_by(OAuthIdentity, provider: provider, provider_uid: uid) do
|
||||
nil ->
|
||||
# Create new user and OAuth identity
|
||||
create_oauth_user(provider, auth, username, email)
|
||||
|
||||
identity ->
|
||||
# Return existing user
|
||||
{:ok, Repo.preload(identity, :user).user}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_oauth_user(provider, auth, username, email) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:user, fn _ ->
|
||||
%User{}
|
||||
|> Ecto.Changeset.change(%{
|
||||
email: email,
|
||||
username: username,
|
||||
display_name: auth.info.name,
|
||||
avatar_url: auth.info.image,
|
||||
confirmed_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
})
|
||||
end)
|
||||
|> Ecto.Multi.insert(:identity, fn %{user: user} ->
|
||||
%OAuthIdentity{}
|
||||
|> OAuthIdentity.changeset(%{
|
||||
user_id: user.id,
|
||||
provider: provider,
|
||||
provider_uid: auth.uid,
|
||||
provider_email: email,
|
||||
provider_login: auth.info.nickname,
|
||||
provider_token: auth.credentials.token,
|
||||
provider_meta: %{
|
||||
name: auth.info.name,
|
||||
image: auth.info.image
|
||||
}
|
||||
})
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, _operation, changeset, _changes} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_username(base) when is_binary(base) do
|
||||
base
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9_]/, "")
|
||||
|> String.slice(0, 10)
|
||||
|> ensure_unique_username()
|
||||
end
|
||||
|
||||
defp generate_username(_), do: ensure_unique_username("user")
|
||||
|
||||
defp ensure_unique_username(username) do
|
||||
case Repo.get_by(User, username: username) do
|
||||
nil ->
|
||||
username
|
||||
|
||||
_ ->
|
||||
random_suffix = :rand.uniform(9999)
|
||||
ensure_unique_username("#{username}#{random_suffix}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
27
lib/malarkey/accounts/oauth_identity.ex
Normal file
27
lib/malarkey/accounts/oauth_identity.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Malarkey.Accounts.OAuthIdentity do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "oauth_identities" do
|
||||
field :provider, :string
|
||||
field :provider_uid, :string
|
||||
field :provider_email, :string
|
||||
field :provider_login, :string
|
||||
field :provider_token, :string
|
||||
field :provider_meta, :map
|
||||
|
||||
belongs_to :user, Malarkey.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(oauth_identity, attrs) do
|
||||
oauth_identity
|
||||
|> cast(attrs, [:provider, :provider_uid, :provider_email, :provider_login, :provider_token, :provider_meta, :user_id])
|
||||
|> validate_required([:provider, :provider_uid])
|
||||
|> unique_constraint([:provider, :provider_uid])
|
||||
end
|
||||
end
|
||||
@@ -1,36 +1,37 @@
|
||||
defmodule Malarkey.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Malarkey.Timeline.Post
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :username, :string
|
||||
field :display_name, :string
|
||||
field :bio, :string
|
||||
field :location, :string
|
||||
field :website, :string
|
||||
field :avatar_url, :string
|
||||
field :header_url, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :verified, :boolean, default: false
|
||||
field :followers_count, :integer, default: 0
|
||||
field :following_count, :integer, default: 0
|
||||
field :posts_count, :integer, default: 0
|
||||
field :confirmed_at, :naive_datetime
|
||||
field :username, :string
|
||||
field :fullname, :string
|
||||
|
||||
many_to_many(
|
||||
:likes,
|
||||
Post,
|
||||
join_through: Malarkey.Timeline.PostUserLike,
|
||||
on_replace: :delete
|
||||
)
|
||||
has_many :posts, Malarkey.Social.Post
|
||||
has_many :likes, Malarkey.Social.Like
|
||||
has_many :oauth_identities, Malarkey.Accounts.OAuthIdentity
|
||||
|
||||
many_to_many(
|
||||
:dislikes,
|
||||
Post,
|
||||
join_through: Malarkey.Timeline.PostUserDislike,
|
||||
on_replace: :delete
|
||||
)
|
||||
many_to_many :followers, Malarkey.Accounts.User,
|
||||
join_through: "follows",
|
||||
join_keys: [following_id: :id, follower_id: :id]
|
||||
|
||||
many_to_many(
|
||||
:reposts,
|
||||
Post,
|
||||
join_through: Malarkey.Timeline.PostUserReposts,
|
||||
on_replace: :delete
|
||||
)
|
||||
many_to_many :following, Malarkey.Accounts.User,
|
||||
join_through: "follows",
|
||||
join_keys: [follower_id: :id, following_id: :id]
|
||||
|
||||
timestamps()
|
||||
end
|
||||
@@ -60,8 +61,9 @@ defmodule Malarkey.Accounts.User do
|
||||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password])
|
||||
|> cast(attrs, [:email, :password, :username, :display_name])
|
||||
|> validate_email(opts)
|
||||
|> validate_username()
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@@ -73,10 +75,21 @@ defmodule Malarkey.Accounts.User do
|
||||
|> maybe_validate_unique_email(opts)
|
||||
end
|
||||
|
||||
defp validate_username(changeset) do
|
||||
changeset
|
||||
|> validate_required([:username])
|
||||
|> validate_length(:username, min: 3, max: 15)
|
||||
|> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/,
|
||||
message: "can only contain letters, numbers, and underscores")
|
||||
|> unsafe_validate_unique(:username, Malarkey.Repo)
|
||||
|> unique_constraint(:username)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
|> validate_length(:password, min: 8, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
@@ -91,7 +104,8 @@ defmodule Malarkey.Accounts.User do
|
||||
changeset
|
||||
# If using Bcrypt, then further validate it is at most 72 bytes long
|
||||
|> validate_length(:password, max: 72, count: :bytes)
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
# Hashing could be done with `Bcrypt`, but we use `Pbkdf2` for security.
|
||||
|> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
@@ -142,6 +156,18 @@ defmodule Malarkey.Accounts.User do
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for profile updates.
|
||||
"""
|
||||
def profile_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:display_name, :bio, :location, :website, :avatar_url, :header_url])
|
||||
|> validate_length(:display_name, max: 50)
|
||||
|> validate_length(:bio, max: 160)
|
||||
|> validate_length(:location, max: 30)
|
||||
|> validate_length(:website, max: 100)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
@@ -154,15 +180,15 @@ defmodule Malarkey.Accounts.User do
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
`Pbkdf2.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Malarkey.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
Pbkdf2.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
Pbkdf2.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
defmodule Malarkey.Accounts.UserNotifier do
|
||||
import Swoosh.Email
|
||||
|
||||
alias Malarkey.Mailer
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"Malarkey", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, "Confirmation instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(user.email, "Reset password instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,9 @@
|
||||
defmodule Malarkey.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
alias Malarkey.Accounts.UserToken
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
@@ -28,22 +29,12 @@ defmodule Malarkey.Accounts.UserToken do
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix' default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual user
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
though Phoenix already provides a session cookie, is to allow
|
||||
users to explicitly invalidate any of their open sessions.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %UserToken{token: token, context: "session", user_id: user.id}}
|
||||
{token, %Malarkey.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -86,7 +77,7 @@ defmodule Malarkey.Accounts.UserToken do
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%UserToken{
|
||||
%Malarkey.Accounts.UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
@@ -163,17 +154,17 @@ defmodule Malarkey.Accounts.UserToken do
|
||||
Returns the token struct for the given token value and context.
|
||||
"""
|
||||
def token_and_context_query(token, context) do
|
||||
from UserToken, where: [token: ^token, context: ^context]
|
||||
from Malarkey.Accounts.UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def user_and_contexts_query(user, :all) do
|
||||
from t in UserToken, where: t.user_id == ^user.id
|
||||
from t in Malarkey.Accounts.UserToken, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
from t in Malarkey.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,18 +8,16 @@ defmodule Malarkey.Application do
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Start the Telemetry supervisor
|
||||
MalarkeyWeb.Telemetry,
|
||||
# Start the Ecto repository
|
||||
Malarkey.Repo,
|
||||
# Start the PubSub system
|
||||
{DNSCluster, query: Application.get_env(:malarkey, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Malarkey.PubSub},
|
||||
# Start Finch
|
||||
# Start the Finch HTTP client for sending emails
|
||||
{Finch, name: Malarkey.Finch},
|
||||
# Start the Endpoint (http/https)
|
||||
MalarkeyWeb.Endpoint
|
||||
# Start a worker by calling: Malarkey.Worker.start_link(arg)
|
||||
# {Malarkey.Worker, arg}
|
||||
# {Malarkey.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
MalarkeyWeb.Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
|
||||
81
lib/malarkey/giphy.ex
Normal file
81
lib/malarkey/giphy.ex
Normal file
@@ -0,0 +1,81 @@
|
||||
defmodule Malarkey.Giphy do
|
||||
@moduledoc """
|
||||
Client for Giphy API integration.
|
||||
"""
|
||||
|
||||
@api_key Application.compile_env(:malarkey, :giphy_api_key, "")
|
||||
@base_url "https://api.giphy.com/v1/gifs"
|
||||
|
||||
@doc """
|
||||
Search for GIFs on Giphy.
|
||||
"""
|
||||
def search(query, limit \\ 25, offset \\ 0) do
|
||||
params = %{
|
||||
api_key: @api_key,
|
||||
q: query,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
rating: "g",
|
||||
lang: "en"
|
||||
}
|
||||
|
||||
case HTTPoison.get("#{@base_url}/search", [], params: params) do
|
||||
{:ok, %{status_code: 200, body: body}} ->
|
||||
case Jason.decode(body) do
|
||||
{:ok, %{"data" => gifs}} ->
|
||||
{:ok, parse_gifs(gifs)}
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
|
||||
{:ok, %{status_code: status}} ->
|
||||
{:error, "Giphy API returned status #{status}"}
|
||||
|
||||
{:error, %{reason: reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get trending GIFs from Giphy.
|
||||
"""
|
||||
def trending(limit \\ 25, offset \\ 0) do
|
||||
params = %{
|
||||
api_key: @api_key,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
rating: "g"
|
||||
}
|
||||
|
||||
case HTTPoison.get("#{@base_url}/trending", [], params: params) do
|
||||
{:ok, %{status_code: 200, body: body}} ->
|
||||
case Jason.decode(body) do
|
||||
{:ok, %{"data" => gifs}} ->
|
||||
{:ok, parse_gifs(gifs)}
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
|
||||
{:ok, %{status_code: status}} ->
|
||||
{:error, "Giphy API returned status #{status}"}
|
||||
|
||||
{:error, %{reason: reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp parse_gifs(gifs) do
|
||||
Enum.map(gifs, fn gif ->
|
||||
%{
|
||||
id: gif["id"],
|
||||
title: gif["title"],
|
||||
url: get_in(gif, ["images", "fixed_height", "url"]),
|
||||
preview_url: get_in(gif, ["images", "fixed_height_small", "url"]),
|
||||
width: get_in(gif, ["images", "fixed_height", "width"]),
|
||||
height: get_in(gif, ["images", "fixed_height", "height"])
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
90
lib/malarkey/media.ex
Normal file
90
lib/malarkey/media.ex
Normal file
@@ -0,0 +1,90 @@
|
||||
defmodule Malarkey.Media do
|
||||
@moduledoc """
|
||||
Context for handling media uploads to the local filesystem.
|
||||
"""
|
||||
|
||||
@upload_dir "priv/static/uploads"
|
||||
|
||||
@doc """
|
||||
Uploads a file to the local filesystem and returns the public URL.
|
||||
"""
|
||||
def upload_file(file_path, content_type, user_id) do
|
||||
file_name = generate_filename(file_path, user_id)
|
||||
dest_path = Path.join(@upload_dir, file_name)
|
||||
File.mkdir_p!(Path.dirname(dest_path))
|
||||
case File.cp(file_path, dest_path) do
|
||||
:ok -> {:ok, get_public_url(file_name)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Uploads binary data (from paste) to the local filesystem.
|
||||
"""
|
||||
def upload_binary(binary, content_type, user_id, extension \\ ".png") do
|
||||
file_name = generate_binary_filename(user_id, extension)
|
||||
dest_path = Path.join(@upload_dir, file_name)
|
||||
File.mkdir_p!(Path.dirname(dest_path))
|
||||
case File.write(dest_path, binary) do
|
||||
:ok -> {:ok, get_public_url(file_name)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines media type from content type or URL.
|
||||
"""
|
||||
def get_media_type(content_type) when is_binary(content_type) do
|
||||
cond do
|
||||
String.starts_with?(content_type, "image/") -> "image"
|
||||
String.starts_with?(content_type, "video/") -> "video"
|
||||
true -> "unknown"
|
||||
end
|
||||
end
|
||||
|
||||
def get_media_type(_), do: "unknown"
|
||||
|
||||
@doc """
|
||||
Validates file size (10MB for images, 50MB for videos).
|
||||
"""
|
||||
def validate_file_size(size, type) do
|
||||
max_size = case type do
|
||||
"image" -> 10 * 1024 * 1024 # 10MB
|
||||
"video" -> 50 * 1024 * 1024 # 50MB
|
||||
_ -> 10 * 1024 * 1024
|
||||
end
|
||||
|
||||
if size <= max_size do
|
||||
:ok
|
||||
else
|
||||
{:error, "File too large. Maximum size is #{format_bytes(max_size)}"}
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp generate_filename(file_path, user_id) do
|
||||
extension = Path.extname(file_path)
|
||||
timestamp = DateTime.utc_now() |> DateTime.to_unix()
|
||||
random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
|
||||
"#{user_id}/#{timestamp}_#{random}#{extension}"
|
||||
end
|
||||
|
||||
defp generate_binary_filename(user_id, extension) do
|
||||
timestamp = DateTime.utc_now() |> DateTime.to_unix()
|
||||
random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
|
||||
"#{user_id}/#{timestamp}_#{random}#{extension}"
|
||||
end
|
||||
|
||||
defp get_public_url(file_name) do
|
||||
"/uploads/#{file_name}"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) do
|
||||
cond do
|
||||
bytes >= 1024 * 1024 -> "#{div(bytes, 1024 * 1024)}MB"
|
||||
bytes >= 1024 -> "#{div(bytes, 1024)}KB"
|
||||
true -> "#{bytes}B"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,28 +0,0 @@
|
||||
defmodule Malarkey.Release do
|
||||
@moduledoc """
|
||||
Used for executing DB release tasks when run in production without Mix
|
||||
installed.
|
||||
"""
|
||||
@app :malarkey
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
end
|
||||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
defp repos do
|
||||
Application.fetch_env!(@app, :ecto_repos)
|
||||
end
|
||||
|
||||
defp load_app do
|
||||
Application.load(@app)
|
||||
end
|
||||
end
|
||||
575
lib/malarkey/social.ex
Normal file
575
lib/malarkey/social.ex
Normal file
@@ -0,0 +1,575 @@
|
||||
defmodule Malarkey.Social do
|
||||
@moduledoc """
|
||||
The Social context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Malarkey.Repo
|
||||
|
||||
alias Malarkey.Social.{Post, Like, Follow}
|
||||
alias Malarkey.Accounts.User
|
||||
|
||||
@doc """
|
||||
Returns the list of posts for the home timeline.
|
||||
Includes posts from users that the given user follows.
|
||||
"""
|
||||
def list_timeline_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
following_ids =
|
||||
from(f in Follow,
|
||||
where: f.follower_id == ^user.id,
|
||||
select: f.following_id
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
user_ids = [user.id | following_ids]
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id in ^user_ids,
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :repost_of, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts for a specific user.
|
||||
"""
|
||||
def list_user_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user.id and is_nil(t.repost_of_id),
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts with media for a specific user.
|
||||
"""
|
||||
def list_user_media_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user.id and fragment("cardinality(?) > 0", t.media_urls),
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts liked by a specific user.
|
||||
"""
|
||||
def list_user_liked_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
join: l in Like,
|
||||
on: l.post_id == t.id,
|
||||
where: l.user_id == ^user.id,
|
||||
order_by: [desc: l.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single post.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Post does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_post!(123)
|
||||
%Post{}
|
||||
|
||||
iex> get_post!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@doc """
|
||||
Gets a single post, raising if not found.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
"""
|
||||
def get_post!(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
Post
|
||||
|> Repo.get!(uuid)
|
||||
|> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
|
||||
:error ->
|
||||
raise Ecto.NoResultsError, queryable: Post
|
||||
end
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Post
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single post without raising.
|
||||
|
||||
Returns `nil` if the Post does not exist.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_post("123e4567-e89b-12d3-a456-426614174000")
|
||||
%Post{}
|
||||
|
||||
iex> get_post("invalid")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_post(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
case Repo.get(Post, uuid) do
|
||||
nil -> nil
|
||||
post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def get_post(id) do
|
||||
case Repo.get(Post, id) do
|
||||
nil -> nil
|
||||
post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets post replies.
|
||||
"""
|
||||
def list_post_replies(post, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
|
||||
from(t in Post,
|
||||
where: t.reply_to_id == ^post.id,
|
||||
order_by: [asc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets threaded replies for a post with nested structure.
|
||||
Returns a map of post_id => replies for building thread trees.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
"""
|
||||
def get_threaded_replies(post_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 500)
|
||||
|
||||
# Convert string UUID to binary format for Postgres
|
||||
uuid = case Ecto.UUID.dump(post_id) do
|
||||
{:ok, binary_uuid} -> binary_uuid
|
||||
:error -> post_id # Already in binary format
|
||||
end
|
||||
|
||||
# Get all replies in the thread recursively
|
||||
query = """
|
||||
WITH RECURSIVE reply_tree AS (
|
||||
-- Base case: direct replies to the post
|
||||
SELECT p.*, 0 as depth, ARRAY[p.id] as path
|
||||
FROM posts p
|
||||
WHERE p.reply_to_id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: replies to replies
|
||||
SELECT p.*, rt.depth + 1, rt.path || p.id
|
||||
FROM posts p
|
||||
INNER JOIN reply_tree rt ON p.reply_to_id = rt.id
|
||||
WHERE NOT p.id = ANY(rt.path) -- Prevent cycles
|
||||
AND rt.depth < 10 -- Limit depth
|
||||
)
|
||||
SELECT * FROM reply_tree ORDER BY path LIMIT $2
|
||||
"""
|
||||
|
||||
result = Ecto.Adapters.SQL.query!(Repo, query, [uuid, limit])
|
||||
|
||||
# Convert results to Post structs and preload associations
|
||||
reply_ids = Enum.map(result.rows, fn row -> Enum.at(row, 0) end)
|
||||
|
||||
from(p in Post,
|
||||
where: p.id in ^reply_ids,
|
||||
preload: [:user, :reply_to]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.group_by(& &1.reply_to_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_post(%{field: value})
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> create_post(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_post(attrs \\ %{}) do
|
||||
result =
|
||||
%Post{}
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, post} ->
|
||||
# Increment user's posts count
|
||||
from(u in User, where: u.id == ^post.user_id)
|
||||
|> Repo.update_all(inc: [posts_count: 1])
|
||||
|
||||
# If it's a reply, increment the parent's replies count
|
||||
if post.reply_to_id do
|
||||
from(t in Post, where: t.id == ^post.reply_to_id)
|
||||
|> Repo.update_all(inc: [replies_count: 1])
|
||||
end
|
||||
|
||||
# Broadcast the new post
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"posts:new",
|
||||
{:new_post, Repo.preload(post, [:user, :reply_to, :quote_post])}
|
||||
)
|
||||
|
||||
{:ok, Repo.preload(post, [:user, :reply_to, :quote_post])}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has reposted a post.
|
||||
"""
|
||||
def reposted_by_user?(user_id, post_id) do
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a repost.
|
||||
"""
|
||||
def create_repost(user_id, post_id) do
|
||||
# Check if already reposted
|
||||
existing =
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|
||||
if existing do
|
||||
{:error, :already_reposted}
|
||||
else
|
||||
result =
|
||||
%Post{}
|
||||
|> Post.repost_changeset(%{user_id: user_id, repost_of_id: post_id})
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, repost} ->
|
||||
# Increment the original post's reposts count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [reposts_count: 1])
|
||||
|
||||
# Increment user's posts count
|
||||
from(u in User, where: u.id == ^user_id)
|
||||
|> Repo.update_all(inc: [posts_count: 1])
|
||||
|
||||
{:ok, Repo.preload(repost, [:user, :repost_of])}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a repost.
|
||||
"""
|
||||
def delete_repost(user_id, post_id) do
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
repost ->
|
||||
Repo.delete(repost)
|
||||
|
||||
# Decrement the original post's reposts count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [reposts_count: -1])
|
||||
|
||||
# Decrement user's posts count
|
||||
from(u in User, where: u.id == ^user_id)
|
||||
|> Repo.update_all(inc: [posts_count: -1])
|
||||
|
||||
{:ok, repost}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_post(post, %{field: new_value})
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> update_post(post, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_post(%Post{} = post, attrs) do
|
||||
post
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_post(post)
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> delete_post(post)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_post(%Post{} = post) do
|
||||
result = Repo.delete(post)
|
||||
|
||||
case result do
|
||||
{:ok, deleted_post} ->
|
||||
# Decrement user's posts count
|
||||
from(u in User, where: u.id == ^deleted_post.user_id)
|
||||
|> Repo.update_all(inc: [posts_count: -1])
|
||||
|
||||
{:ok, deleted_post}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking post changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_post(post)
|
||||
%Ecto.Changeset{data: %Post{}}
|
||||
|
||||
"""
|
||||
def change_post(%Post{} = post, attrs \\ %{}) do
|
||||
Post.changeset(post, attrs)
|
||||
end
|
||||
|
||||
## Likes
|
||||
|
||||
@doc """
|
||||
Creates a like.
|
||||
"""
|
||||
def create_like(attrs \\ %{}) do
|
||||
result =
|
||||
%Like{}
|
||||
|> Like.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, like} ->
|
||||
# Increment post's likes count
|
||||
from(t in Post, where: t.id == ^like.post_id)
|
||||
|> Repo.update_all(inc: [likes_count: 1])
|
||||
|
||||
# Broadcast the like
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"post:#{like.post_id}",
|
||||
{:like_added, like}
|
||||
)
|
||||
|
||||
{:ok, like}
|
||||
|
||||
{:error, changeset} ->
|
||||
# If it's a unique constraint error, it means already liked
|
||||
if changeset.errors[:user_id] || changeset.errors[:post_id] do
|
||||
{:error, :already_liked}
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a like.
|
||||
"""
|
||||
def delete_like(user_id, post_id) do
|
||||
from(l in Like,
|
||||
where: l.user_id == ^user_id and l.post_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
like ->
|
||||
Repo.delete(like)
|
||||
|
||||
# Decrement post's likes count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [likes_count: -1])
|
||||
|
||||
# Broadcast the unlike
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"post:#{post_id}",
|
||||
{:like_removed, like}
|
||||
)
|
||||
|
||||
{:ok, like}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has liked a post.
|
||||
"""
|
||||
def liked_by_user?(user_id, post_id) do
|
||||
Repo.exists?(
|
||||
from l in Like,
|
||||
where: l.user_id == ^user_id and l.post_id == ^post_id
|
||||
)
|
||||
end
|
||||
|
||||
## Follows
|
||||
|
||||
@doc """
|
||||
Creates a follow relationship.
|
||||
"""
|
||||
def create_follow(attrs \\ %{}) do
|
||||
result =
|
||||
%Follow{}
|
||||
|> Follow.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, follow} ->
|
||||
# Increment follower's following count
|
||||
from(u in User, where: u.id == ^follow.follower_id)
|
||||
|> Repo.update_all(inc: [following_count: 1])
|
||||
|
||||
# Increment following's followers count
|
||||
from(u in User, where: u.id == ^follow.following_id)
|
||||
|> Repo.update_all(inc: [followers_count: 1])
|
||||
|
||||
# Broadcast the follow
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"user:#{follow.following_id}",
|
||||
{:new_follower, follow}
|
||||
)
|
||||
|
||||
{:ok, follow}
|
||||
|
||||
{:error, changeset} ->
|
||||
# If it's a unique constraint error, it means already following
|
||||
if changeset.errors[:follower_id] || changeset.errors[:following_id] do
|
||||
{:error, :already_following}
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a follow relationship.
|
||||
"""
|
||||
def delete_follow(follower_id, following_id) do
|
||||
from(f in Follow,
|
||||
where: f.follower_id == ^follower_id and f.following_id == ^following_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
follow ->
|
||||
Repo.delete(follow)
|
||||
|
||||
# Decrement follower's following count
|
||||
from(u in User, where: u.id == ^follower_id)
|
||||
|> Repo.update_all(inc: [following_count: -1])
|
||||
|
||||
# Decrement following's followers count
|
||||
from(u in User, where: u.id == ^following_id)
|
||||
|> Repo.update_all(inc: [followers_count: -1])
|
||||
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user is following another user.
|
||||
"""
|
||||
def following?(follower_id, following_id) do
|
||||
Repo.exists?(
|
||||
from f in Follow,
|
||||
where: f.follower_id == ^follower_id and f.following_id == ^following_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a list of followers for a user.
|
||||
"""
|
||||
def list_followers(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(u in User,
|
||||
join: f in Follow,
|
||||
on: f.follower_id == u.id,
|
||||
where: f.following_id == ^user.id,
|
||||
order_by: [desc: f.inserted_at],
|
||||
limit: ^limit
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a list of users that a user is following.
|
||||
"""
|
||||
def list_following(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(u in User,
|
||||
join: f in Follow,
|
||||
on: f.following_id == u.id,
|
||||
where: f.follower_id == ^user.id,
|
||||
order_by: [desc: f.inserted_at],
|
||||
limit: ^limit
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
24
lib/malarkey/social/follow.ex
Normal file
24
lib/malarkey/social/follow.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule Malarkey.Social.Follow do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "follows" do
|
||||
belongs_to :follower, Malarkey.Accounts.User
|
||||
belongs_to :following, Malarkey.Accounts.User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(follow, attrs) do
|
||||
follow
|
||||
|> cast(attrs, [:follower_id, :following_id])
|
||||
|> validate_required([:follower_id, :following_id])
|
||||
|> unique_constraint([:follower_id, :following_id])
|
||||
|> check_constraint(:follower_id, name: :cannot_follow_self, message: "cannot follow yourself")
|
||||
|> foreign_key_constraint(:follower_id)
|
||||
|> foreign_key_constraint(:following_id)
|
||||
end
|
||||
end
|
||||
23
lib/malarkey/social/like.ex
Normal file
23
lib/malarkey/social/like.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule Malarkey.Social.Like do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "likes" do
|
||||
belongs_to :user, Malarkey.Accounts.User
|
||||
belongs_to :post, Malarkey.Social.Post
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(like, attrs) do
|
||||
like
|
||||
|> cast(attrs, [:user_id, :post_id])
|
||||
|> validate_required([:user_id, :post_id])
|
||||
|> unique_constraint([:user_id, :post_id])
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:post_id)
|
||||
end
|
||||
end
|
||||
93
lib/malarkey/social/post.ex
Normal file
93
lib/malarkey/social/post.ex
Normal file
@@ -0,0 +1,93 @@
|
||||
defmodule Malarkey.Social.Post do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "posts" do
|
||||
field :body, :string, source: :content
|
||||
field :media_urls, {:array, :string}, default: []
|
||||
field :media_types, {:array, :string}, default: []
|
||||
field :likes_count, :integer, default: 0
|
||||
field :reposts_count, :integer, default: 0
|
||||
field :replies_count, :integer, default: 0
|
||||
field :views_count, :integer, default: 0
|
||||
|
||||
belongs_to :user, Malarkey.Accounts.User
|
||||
belongs_to :reply_to, Malarkey.Social.Post
|
||||
belongs_to :repost_of, Malarkey.Social.Post
|
||||
belongs_to :quote_post, Malarkey.Social.Post
|
||||
|
||||
has_many :likes, Malarkey.Social.Like
|
||||
has_many :replies, Malarkey.Social.Post, foreign_key: :reply_to_id
|
||||
has_many :reposts, Malarkey.Social.Post, foreign_key: :repost_of_id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(post, attrs) do
|
||||
# Normalize attrs to support both body and content keys
|
||||
attrs = normalize_attrs(attrs)
|
||||
|
||||
post
|
||||
|> cast(attrs, [:body, :media_urls, :media_types, :user_id, :reply_to_id, :repost_of_id, :quote_post_id], empty_values: [])
|
||||
|> ensure_body_for_media()
|
||||
|> validate_required([:user_id])
|
||||
|> validate_body_or_media()
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:reply_to_id)
|
||||
|> foreign_key_constraint(:repost_of_id)
|
||||
|> foreign_key_constraint(:quote_post_id)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def repost_changeset(post, attrs) do
|
||||
post
|
||||
|> cast(attrs, [:user_id, :repost_of_id])
|
||||
|> validate_required([:user_id, :repost_of_id])
|
||||
|> put_change(:body, " ")
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:repost_of_id)
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp normalize_attrs(attrs) when is_map(attrs) do
|
||||
# Support both body and content keys for migration
|
||||
cond do
|
||||
Map.has_key?(attrs, :body) or Map.has_key?(attrs, "body") -> attrs
|
||||
Map.has_key?(attrs, :content) -> Map.put(attrs, :body, attrs[:content])
|
||||
Map.has_key?(attrs, "content") -> Map.put(attrs, :body, attrs["content"])
|
||||
true -> attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_body_for_media(changeset) do
|
||||
# After cast, if body is nil/empty and media is present, set body to a space
|
||||
body = get_field(changeset, :body)
|
||||
media_urls = get_field(changeset, :media_urls) || []
|
||||
|
||||
trimmed_body = if is_binary(body), do: String.trim(body), else: ""
|
||||
|
||||
if trimmed_body == "" and length(media_urls) > 0 do
|
||||
put_change(changeset, :body, " ")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_body_or_media(changeset) do
|
||||
body = get_field(changeset, :body)
|
||||
media_urls = get_field(changeset, :media_urls) || []
|
||||
|
||||
trimmed_body = if is_binary(body), do: String.trim(body), else: ""
|
||||
|
||||
if trimmed_body == "" && Enum.empty?(media_urls) do
|
||||
add_error(changeset, :body, "Post must have text or media")
|
||||
else
|
||||
changeset
|
||||
|> validate_length(:body, max: 280, message: "Post is too long (maximum is 280 characters)")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,82 +0,0 @@
|
||||
defmodule Malarkey.Timeline do
|
||||
@moduledoc """
|
||||
The Timeline context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Repo
|
||||
alias Malarkey.Timeline.Post
|
||||
|
||||
def list_posts() do
|
||||
# preloads = Keyword.get(opts, :preloads, [])
|
||||
|
||||
Post
|
||||
|> order_by(desc: :inserted_at)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:user)
|
||||
|> Repo.preload(:liked_by)
|
||||
|> Repo.preload(:disliked_by)
|
||||
|> Repo.preload(:reposted_by)
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Repo.get!(Post, id)
|
||||
|> Repo.preload(:user)
|
||||
|> Repo.preload(:liked_by)
|
||||
|> Repo.preload(:disliked_by)
|
||||
|> Repo.preload(:reposted_by)
|
||||
end
|
||||
|
||||
def create_post(user, attrs \\ %{}) do
|
||||
%Post{user_id: user.id}
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|> broadcast(:post_created)
|
||||
end
|
||||
|
||||
def update_post(%Post{} = post, attrs \\ %{}) do
|
||||
post
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> broadcast(:post_created)
|
||||
end
|
||||
|
||||
def delete_post(%Post{} = post) do
|
||||
Repo.delete(post)
|
||||
end
|
||||
|
||||
def change_post(%Post{} = post, attrs \\ %{}) do
|
||||
Post.changeset(post, attrs)
|
||||
end
|
||||
|
||||
def add_like(user, post) do
|
||||
post
|
||||
|> Repo.preload(:liked_by)
|
||||
|> Repo.preload(:user)
|
||||
|> Post.changeset_add_like(user)
|
||||
|> Repo.update()
|
||||
|> broadcast(:post_created)
|
||||
end
|
||||
|
||||
def add_repost(user, post) do
|
||||
post
|
||||
|> Repo.preload(:reposted_by)
|
||||
|> Repo.preload(:user)
|
||||
|> Post.changeset_add_repost(user)
|
||||
|> Repo.update()
|
||||
|> broadcast(:post_created)
|
||||
end
|
||||
|
||||
def subscribe do
|
||||
Phoenix.PubSub.subscribe(Malarkey.PubSub, "posts")
|
||||
end
|
||||
|
||||
defp broadcast({:error, _reason} = error, _event), do: error
|
||||
|
||||
defp broadcast({:ok, post}, event) do
|
||||
Phoenix.PubSub.broadcast!(Malarkey.PubSub, "posts", {event, post})
|
||||
{:ok, post}
|
||||
end
|
||||
end
|
||||
@@ -1,63 +0,0 @@
|
||||
defmodule Malarkey.Timeline.Post do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Malarkey.Accounts.User
|
||||
|
||||
schema "posts" do
|
||||
field :body, :string
|
||||
belongs_to(:user, User)
|
||||
|
||||
many_to_many(
|
||||
:liked_by,
|
||||
User,
|
||||
join_through: Malarkey.Timeline.PostUserLike,
|
||||
on_replace: :delete
|
||||
)
|
||||
|
||||
many_to_many(
|
||||
:disliked_by,
|
||||
User,
|
||||
join_through: Malarkey.Timeline.PostUserDislike,
|
||||
on_replace: :delete
|
||||
)
|
||||
|
||||
many_to_many(
|
||||
:reposted_by,
|
||||
User,
|
||||
join_through: Malarkey.Timeline.PostUserRepost,
|
||||
on_replace: :delete
|
||||
)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@spec changeset(
|
||||
{map, map}
|
||||
| %{
|
||||
:__struct__ => atom | %{:__changeset__ => map, optional(any) => any},
|
||||
optional(atom) => any
|
||||
},
|
||||
:invalid | %{optional(:__struct__) => none, optional(atom | binary) => any}
|
||||
) :: Ecto.Changeset.t()
|
||||
@doc false
|
||||
def changeset(post, attrs) do
|
||||
post
|
||||
|> cast(attrs, [:body, :user_id])
|
||||
|> cast_assoc(:liked_by, required: false)
|
||||
|> validate_required([:body])
|
||||
|> validate_required([:user_id])
|
||||
|> validate_length(:body, min: 2, max: 250)
|
||||
end
|
||||
|
||||
def changeset_add_like(post, user, attrs \\ %{}) do
|
||||
post
|
||||
|> changeset(attrs)
|
||||
|> put_assoc(:liked_by, [user])
|
||||
end
|
||||
|
||||
def changeset_add_repost(post, user, attrs \\ %{}) do
|
||||
post
|
||||
|> changeset(attrs)
|
||||
|> put_assoc(:reposted_by, [user])
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
defmodule Malarkey.Timeline.PostUserDislike do
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key false
|
||||
schema "user_dislikes" do
|
||||
belongs_to :user, Malarkey.Accounts.User, primary_key: true
|
||||
belongs_to :post, Malarkey.Timeline.Post, primary_key: true
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
defmodule Malarkey.Timeline.PostUserLike do
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key false
|
||||
schema "user_likes" do
|
||||
belongs_to :user, Malarkey.Accounts.User, primary_key: true
|
||||
belongs_to :post, Malarkey.Timeline.Post, primary_key: true
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
defmodule Malarkey.Timeline.PostUserRepost do
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key false
|
||||
schema "user_reposts" do
|
||||
belongs_to :user, Malarkey.Accounts.User, primary_key: true
|
||||
belongs_to :post, Malarkey.Timeline.Post, primary_key: true
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
@@ -39,12 +39,12 @@ defmodule MalarkeyWeb do
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller,
|
||||
namespace: MalarkeyWeb,
|
||||
formats: [:html, :json],
|
||||
layouts: [html: MalarkeyWeb.Layouts]
|
||||
|
||||
use Gettext, backend: MalarkeyWeb.Gettext
|
||||
|
||||
import Plug.Conn
|
||||
import MalarkeyWeb.Gettext
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
@@ -67,9 +67,6 @@ defmodule MalarkeyWeb do
|
||||
end
|
||||
end
|
||||
|
||||
@spec html ::
|
||||
{:__block__, [],
|
||||
[{:__block__, [], [...]} | {:import, [...], [...]} | {:use, [...], [...]}, ...]}
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
@@ -85,11 +82,14 @@ defmodule MalarkeyWeb do
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# Translation
|
||||
use Gettext, backend: MalarkeyWeb.Gettext
|
||||
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components and translation
|
||||
# Core UI components
|
||||
import MalarkeyWeb.CoreComponents
|
||||
import MalarkeyWeb.Gettext
|
||||
import MalarkeyWeb.Components.Avatar
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
@@ -109,7 +109,7 @@ defmodule MalarkeyWeb do
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/view/etc.
|
||||
When used, dispatch to the appropriate controller/live_view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
|
||||
53
lib/malarkey_web/components/avatar.ex
Normal file
53
lib/malarkey_web/components/avatar.ex
Normal file
@@ -0,0 +1,53 @@
|
||||
defmodule MalarkeyWeb.Components.Avatar do
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a user avatar with fallback to initials.
|
||||
|
||||
## Examples
|
||||
|
||||
<.avatar user={@user} size="sm" />
|
||||
<.avatar user={@user} size="md" />
|
||||
<.avatar user={@user} size="lg" />
|
||||
<.avatar user={@user} size="xl" />
|
||||
"""
|
||||
attr :user, :map, required: true, doc: "The user struct containing avatar_url and username"
|
||||
attr :size, :string, default: "md", values: ["sm", "md", "lg", "xl"]
|
||||
attr :class, :string, default: "", doc: "Additional CSS classes"
|
||||
|
||||
def avatar(assigns) do
|
||||
~H"""
|
||||
<%= if @user.avatar_url do %>
|
||||
<img
|
||||
src={@user.avatar_url}
|
||||
alt={@user.username}
|
||||
class={[
|
||||
"rounded-full object-cover",
|
||||
size_class(@size),
|
||||
@class
|
||||
]}
|
||||
/>
|
||||
<% else %>
|
||||
<div class={[
|
||||
"rounded-full flex items-center justify-center font-semibold",
|
||||
"bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300",
|
||||
size_class(@size),
|
||||
@class
|
||||
]}>
|
||||
<%= initial(@user) %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp size_class("sm"), do: "w-8 h-8 text-xs"
|
||||
defp size_class("md"), do: "w-10 h-10 text-sm"
|
||||
defp size_class("lg"), do: "w-12 h-12 text-base"
|
||||
defp size_class("xl"), do: "w-16 h-16 text-xl"
|
||||
|
||||
defp initial(user) do
|
||||
(user.display_name || user.username)
|
||||
|> String.first()
|
||||
|> String.upcase()
|
||||
end
|
||||
end
|
||||
@@ -2,17 +2,22 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
The components in this module use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
|
||||
customize the generated components in this module.
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as modals, tables, and
|
||||
forms. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com), using the
|
||||
[heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
|
||||
The default components use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
|
||||
how to customize them or feel free to swap in another framework altogether.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: MalarkeyWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
import MalarkeyWeb.Gettext
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
@@ -20,35 +25,32 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
Are you sure?
|
||||
<:confirm>OK</:confirm>
|
||||
<:cancel>Cancel</:cancel>
|
||||
This is a modal.
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
|
||||
for the caller to react to each button press, for example:
|
||||
JS commands may be passed to the `:on_cancel` to configure
|
||||
the closing/cancel event, for example:
|
||||
|
||||
<.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
|
||||
Are you sure you?
|
||||
<:confirm>OK</:confirm>
|
||||
<:cancel>Cancel</:cancel>
|
||||
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
||||
This is another modal.
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
attr :on_confirm, JS, default: %JS{}
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :title
|
||||
slot :subtitle
|
||||
slot :confirm
|
||||
slot :cancel
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden">
|
||||
<div id={"#{@id}-bg"} class="fixed inset-0 transition-opacity bg-zinc-50/90" aria-hidden="true" />
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
@@ -57,54 +59,27 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center justify-center min-h-full">
|
||||
<div class="flex min-h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-window-keydown={hide_modal(@on_cancel, @id)}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={hide_modal(@on_cancel, @id)}
|
||||
class="relative hidden transition bg-white shadow-lg rounded-2xl p-14 shadow-zinc-700/10 ring-1 ring-zinc-700/10"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={hide_modal(@on_cancel, @id)}
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="flex-none p-3 -m-3 opacity-20 hover:opacity-40"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<%= Heroicons.icon("x-mark", type: "solid", class: "w-5 h-5 stroke-current") %>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<header :if={@title != []}>
|
||||
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@title) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</header>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<div :if={@confirm != [] or @cancel != []} class="flex items-center gap-5 mb-4 ml-6">
|
||||
<.button
|
||||
:for={confirm <- @confirm}
|
||||
id={"#{@id}-confirm"}
|
||||
phx-click={@on_confirm}
|
||||
phx-disable-with
|
||||
class="px-3 py-2"
|
||||
>
|
||||
<%= render_slot(confirm) %>
|
||||
</.button>
|
||||
<.link
|
||||
:for={cancel <- @cancel}
|
||||
phx-click={hide_modal(@on_cancel, @id)}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(cancel) %>
|
||||
</.link>
|
||||
</div>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
@@ -122,74 +97,103 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, default: "flash", doc: "the optional id of flash container"
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
|
||||
attr :close, :boolean, default: true, doc: "whether the flash can be closed"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-mounted={@autoshow && show("##{@id}")}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("#flash")}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
|
||||
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||
@kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6">
|
||||
<%= if @kind == :info do %>
|
||||
<%= Heroicons.icon("information-circle", type: "mini", class: "w-4 h-4") %>
|
||||
<% end %>
|
||||
<%= if @kind == :error do %>
|
||||
<%= Heroicons.icon("exclamation-circle", type: "mini", class: "w-4 h-4") %>
|
||||
<% end %>
|
||||
<%= @title %>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
|
||||
{@title}
|
||||
</p>
|
||||
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p>
|
||||
<button
|
||||
:if={@close}
|
||||
type="button"
|
||||
class="absolute p-2 group top-2 right-1"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<%= Heroicons.icon("x-mark",
|
||||
type: "solid",
|
||||
class: "w-5 h-5 stroke-current opacity-40 group-hover:opacity-70"
|
||||
) %>
|
||||
<p class="mt-2 text-sm leading-5">{msg}</p>
|
||||
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
|
||||
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error")}
|
||||
phx-connected={hide("#client-error")}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error")}
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
{gettext("Hang in there while we get back on track")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a simple form.
|
||||
|
||||
## Examples
|
||||
|
||||
<.simple_form :let={f} for={:user} phx-change="validate" phx-submit="save">
|
||||
<.input field={{f, :email}} label="Email"/>
|
||||
<.input field={{f, :username}} label="Username" />
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label="Email"/>
|
||||
<.input field={@form[:username]} label="Username" />
|
||||
<:actions>
|
||||
<.button>Save</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
"""
|
||||
attr :for, :any, default: nil, doc: "the datastructure for the form"
|
||||
attr :for, :any, required: true, doc: "the data structure for the form"
|
||||
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target),
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
||||
doc: "the arbitrary HTML attributes to apply to the form tag"
|
||||
|
||||
slot :inner_block, required: true
|
||||
@@ -199,9 +203,9 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="mt-10 space-y-8 bg-white">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="flex items-center justify-between gap-6 mt-2">
|
||||
<%= render_slot(action, f) %>
|
||||
{render_slot(@inner_block, f)}
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
{render_slot(action, f)}
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
@@ -233,7 +237,7 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
@@ -241,137 +245,149 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `%Phoenix.HTML.Form{}` and field name may be passed to the input
|
||||
to build input names and error messages, or all the attributes and
|
||||
errors may be passed explicitly.
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={{f, :email}} type="email" />
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
range radio search select tel text textarea time url week)
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
range search select tel text textarea time url week)
|
||||
|
||||
attr :value, :any
|
||||
attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}"
|
||||
attr :errors, :list
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :rest, :global, include: ~w(autocomplete disabled form max maxlength min minlength
|
||||
pattern placeholder readonly required size step)
|
||||
slot :inner_block
|
||||
|
||||
def input(%{field: {f, field}} = assigns) do
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil)
|
||||
|> assign_new(:name, fn ->
|
||||
name = Phoenix.HTML.Form.input_name(f, field)
|
||||
if assigns.multiple, do: name <> "[]", else: name
|
||||
end)
|
||||
|> assign_new(:id, fn -> Phoenix.HTML.Form.input_id(f, field) end)
|
||||
|> assign_new(:value, fn -> Phoenix.HTML.Form.input_value(f, field) end)
|
||||
|> assign_new(:errors, fn -> translate_errors(f.errors || [], field) end)
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns = assign_new(assigns, :checked, fn -> input_equals?(assigns.value, "true") end)
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<label phx-feedback-for={@name} class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id || @name}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<div>
|
||||
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
|
||||
{@rest}
|
||||
/>
|
||||
{@label}
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<div>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="block w-full px-3 py-2 mt-1 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm"
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt}><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<div>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<textarea
|
||||
id={@id || @name}
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
input_border(@errors),
|
||||
"mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
|
||||
"text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6",
|
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5"
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
|
||||
<%= @value %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<div>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id || @name}
|
||||
value={@value}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
input_border(@errors),
|
||||
"mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
|
||||
"text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
|
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5"
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp input_border([] = _errors),
|
||||
do: "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5"
|
||||
|
||||
defp input_border([_ | _] = _errors),
|
||||
do: "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
"""
|
||||
@@ -381,7 +397,7 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
@@ -393,12 +409,9 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="flex gap-3 mt-3 text-sm leading-6 phx-no-feedback:hidden text-rose-600">
|
||||
<%= Heroicons.icon("exclamation-circle",
|
||||
type: "mini",
|
||||
class: "mt-0.5 h-5 w-5 flex-none fill-rose-500"
|
||||
) %>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
@@ -417,13 +430,13 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
@@ -434,13 +447,18 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id"><%= user.id %></:col>
|
||||
<:col :let={user} label="username"><%= user.username %></:col>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :row_click, :any, default: nil
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
@@ -449,43 +467,48 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<div id={@id} class="px-4 overflow-y-auto sm:overflow-visible sm:px-0">
|
||||
<table class="mt-11 w-[40rem] sm:w-full">
|
||||
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500">
|
||||
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
|
||||
<table class="w-[40rem] mt-11 sm:w-full">
|
||||
<thead class="text-sm text-left leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
|
||||
<th :if={@action != []} class="relative p-0 pb-4">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="relative text-sm leading-6 border-t divide-y divide-zinc-100 border-zinc-200 text-zinc-700">
|
||||
<tr
|
||||
:for={row <- @rows}
|
||||
id={"#{@id}-#{Phoenix.Param.to_param(row)}"}
|
||||
class="relative group hover:bg-zinc-50"
|
||||
>
|
||||
<tbody
|
||||
id={@id}
|
||||
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
||||
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
|
||||
>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["p-0", @row_click && "hover:cursor-pointer"]}
|
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div :if={i == 0}>
|
||||
<span class="absolute top-0 w-4 h-full -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class="absolute top-0 w-4 h-full -right-4 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
</div>
|
||||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, row) %>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []} class="p-0 w-14">
|
||||
<div class="relative py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, row) %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -502,8 +525,8 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title"><%= @post.title %></:item>
|
||||
<:item title="Views"><%= @post.views %></:item>
|
||||
<:item title="Title">{@post.title}</:item>
|
||||
<:item title="Views">{@post.views}</:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
@@ -514,9 +537,9 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
~H"""
|
||||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-sm leading-6 text-zinc-700"><%= render_slot(item) %></dd>
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500">{item.title}</dt>
|
||||
<dd class="text-zinc-700">{render_slot(item)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -540,21 +563,46 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= Heroicons.icon("arrow-left",
|
||||
type: "solid",
|
||||
class: "inline w-3 h-3 stroke-current"
|
||||
) %>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all transform ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
@@ -578,9 +626,11 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
|> JS.show(to: "##{id}")
|
||||
|> JS.show(
|
||||
to: "##{id}-bg",
|
||||
time: 300,
|
||||
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> show("##{id}-container")
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
|> JS.focus_first(to: "##{id}-content")
|
||||
end
|
||||
|
||||
@@ -592,6 +642,7 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
)
|
||||
|> hide("##{id}-container")
|
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
|
||||
@@ -602,20 +653,13 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate "is invalid" in the "errors" domain
|
||||
# dgettext("errors", "is invalid")
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# Because the error messages we show in our forms and APIs
|
||||
# are defined inside Ecto, we need to translate them dynamically.
|
||||
# This requires us to call the Gettext module passing our gettext
|
||||
# backend as first argument.
|
||||
#
|
||||
# Note we use the "errors" domain, which means translations
|
||||
# should be written to the errors.po file. The :count option is
|
||||
# set by Ecto and indicates we should also apply plural rules.
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(MalarkeyWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
@@ -629,8 +673,4 @@ defmodule MalarkeyWeb.CoreComponents do
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
defp input_equals?(val1, val2) do
|
||||
Phoenix.HTML.html_escape(val1) == Phoenix.HTML.html_escape(val2)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
defmodule MalarkeyWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds different layouts used by your application.
|
||||
|
||||
See the `layouts` directory for all templates available.
|
||||
The "root" layout is a skeleton rendered as part of the
|
||||
application router. The "app" layout is set as the default
|
||||
layout on both `use MalarkeyWeb, :controller` and
|
||||
`use MalarkeyWeb, :live_view`.
|
||||
"""
|
||||
use MalarkeyWeb, :html
|
||||
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@@ -1,19 +1,86 @@
|
||||
<div class="flex min-h-screen">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="flex-shrink-0 w-20 border-r lg:w-64 border-border">
|
||||
<div class="sticky top-0 flex flex-col h-screen p-4">
|
||||
<div class="mb-4">
|
||||
<.link navigate={~p"/"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-8 h-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
<span class="hidden text-xl font-bold lg:inline">Malarkey</span>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<main class="px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||
<.flash kind={:error} title="Error!" flash={@flash} />
|
||||
<.flash
|
||||
id="disconnected"
|
||||
kind={:error}
|
||||
title="We can't find the internet"
|
||||
close={false}
|
||||
autoshow={false}
|
||||
phx-disconnected={show("#disconnected")}
|
||||
phx-connected={hide("#disconnected")}
|
||||
>
|
||||
Attempting to reconnect <%= Heroicons.icon("arrow-path", type: "solid", class: "inline w-3 h-3 ml-1 animate-spin") %> />
|
||||
</.flash>
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
</main>
|
||||
<nav class="flex-1 space-y-2">
|
||||
<.link navigate={~p"/"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
<span class="hidden font-semibold lg:inline">Home</span>
|
||||
</.link>
|
||||
|
||||
<.link navigate={~p"/explore"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="hidden font-semibold lg:inline">Explore</span>
|
||||
</.link>
|
||||
|
||||
<.link navigate={~p"/notifications"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
<span class="hidden font-semibold lg:inline">Notifications</span>
|
||||
</.link>
|
||||
|
||||
<.link :if={@current_user} navigate={~p"/users/settings"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span class="hidden font-semibold lg:inline">Settings</span>
|
||||
</.link>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto space-y-2">
|
||||
<%= if @current_user do %>
|
||||
<.link navigate={~p"/#{@current_user.username}"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<.avatar user={@current_user} size="md" />
|
||||
<div class="flex-1 hidden min-w-0 lg:block">
|
||||
<p class="text-sm font-semibold truncate"><%= @current_user.display_name || @current_user.username %></p>
|
||||
<p class="text-xs truncate text-muted-foreground">@<%= @current_user.username %></p>
|
||||
</div>
|
||||
</.link>
|
||||
<.link href={~p"/users/log_out"} method="delete" class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span class="hidden lg:inline">Log out</span>
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link navigate={~p"/users/log_in"} class="flex items-center gap-3 p-3 transition-colors rounded-lg hover:bg-accent">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span class="hidden font-semibold lg:inline">Log in</span>
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 min-w-0">
|
||||
{@inner_content}
|
||||
</main>
|
||||
|
||||
<!-- Right Sidebar (optional - for trends, suggestions, etc) -->
|
||||
<aside class="hidden p-4 border-l xl:block w-80 border-border">
|
||||
<div class="sticky top-0">
|
||||
<div class="p-4 rounded-lg bg-muted">
|
||||
<h2 class="mb-4 text-lg font-bold">What's happening</h2>
|
||||
<p class="text-sm text-muted-foreground">Coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="[scrollbar-gutter:stable]">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title suffix=" · Phoenix Framework">
|
||||
<%= assigns[:page_title] || "Malarkey" %>
|
||||
<.live_title default="Malarkey" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased bg-white">
|
||||
<header class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between py-3 border-b border-zinc-100">
|
||||
<a href="/">
|
||||
<div class="flex items-center gap-4">
|
||||
<img class="h-8" src="/images/logo.svg" />
|
||||
<p class="px-2 font-medium leading-6 rounded-full text-md text-brand">
|
||||
Malarkey
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= if @current_user do %>
|
||||
<.link
|
||||
href={~p"/users/log_out"}
|
||||
method="delete"
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70"
|
||||
>
|
||||
Logout <span aria-hidden="true">→</span>
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
href={~p"/users/register"}
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70"
|
||||
>
|
||||
Register <span aria-hidden="true">→</span>
|
||||
</.link>
|
||||
<.link
|
||||
href={~p"/users/log_in"}
|
||||
class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70"
|
||||
>
|
||||
Login <span aria-hidden="true">→</span>
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<%= @inner_content %>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
79
lib/malarkey_web/components/media.ex
Normal file
79
lib/malarkey_web/components/media.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule MalarkeyWeb.Components.Media do
|
||||
@moduledoc """
|
||||
Media display components for posts.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :media_urls, :list, required: true
|
||||
attr :media_types, :list, default: []
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def post_media(assigns) do
|
||||
~H"""
|
||||
<div :if={length(@media_urls) > 0} class={["mt-3 rounded-xl overflow-hidden", @class]}>
|
||||
<%= if length(@media_urls) == 1 do %>
|
||||
<.single_media url={Enum.at(@media_urls, 0)} type={Enum.at(@media_types, 0)} />
|
||||
<% else %>
|
||||
<.media_grid media_urls={@media_urls} media_types={@media_types} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :url, :string, required: true
|
||||
attr :type, :string, default: "image"
|
||||
|
||||
defp single_media(assigns) do
|
||||
~H"""
|
||||
<%= case @type do %>
|
||||
<% "video" -> %>
|
||||
<video controls class="w-full max-h-[500px] bg-black">
|
||||
<source src={@url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<% "gif" -> %>
|
||||
<img src={@url} alt="GIF" class="w-full max-h-[500px] object-contain bg-muted" />
|
||||
<% _ -> %>
|
||||
<img src={@url} alt="Post media" class="w-full max-h-[500px] object-cover" />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :media_urls, :list, required: true
|
||||
attr :media_types, :list, required: true
|
||||
|
||||
defp media_grid(assigns) do
|
||||
assigns = assign(assigns, :media_count, length(assigns.media_urls))
|
||||
|
||||
~H"""
|
||||
<div class={[
|
||||
"grid gap-0.5",
|
||||
@media_count == 2 && "grid-cols-2",
|
||||
@media_count == 3 && "grid-cols-2",
|
||||
@media_count >= 4 && "grid-cols-2"
|
||||
]}>
|
||||
<%= for {url, index} <- Enum.with_index(@media_urls) do %>
|
||||
<%= if index < 4 do %>
|
||||
<div class={[
|
||||
"relative aspect-video overflow-hidden bg-muted",
|
||||
@media_count == 3 && index == 0 && "row-span-2"
|
||||
]}>
|
||||
<%= if Enum.at(@media_types, index) == "video" do %>
|
||||
<video controls class="w-full h-full object-cover">
|
||||
<source src={url} type="video/mp4" />
|
||||
</video>
|
||||
<% else %>
|
||||
<img src={url} alt={"Media #{index + 1}"} class="w-full h-full object-cover" />
|
||||
<% end %>
|
||||
<%= if @media_count > 4 && index == 3 do %>
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-3xl font-bold">+<%= @media_count - 4 %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
165
lib/malarkey_web/components/post_composer.ex
Normal file
165
lib/malarkey_web/components/post_composer.ex
Normal file
@@ -0,0 +1,165 @@
|
||||
defmodule MalarkeyWeb.Components.PostComposer do
|
||||
@moduledoc """
|
||||
Reusable post composer component for creating new posts and replies.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Avatar
|
||||
|
||||
attr :current_user, :map, required: true
|
||||
attr :body, :string, default: ""
|
||||
attr :char_count, :integer, default: 0
|
||||
attr :uploaded_files, :list, default: []
|
||||
attr :uploads, :map, required: true
|
||||
attr :submit_event, :string, required: true
|
||||
attr :update_event, :string, required: true
|
||||
attr :placeholder, :string, default: "What's happening?"
|
||||
attr :reply_to, :map, default: nil
|
||||
attr :show_cancel, :boolean, default: false
|
||||
attr :cancel_event, :string, default: nil
|
||||
|
||||
def post_composer(assigns) do
|
||||
# Generate unique IDs based on whether it's a reply or not
|
||||
assigns = assign(assigns, :form_id, if(assigns.reply_to, do: "reply-composer-form", else: "post-composer-form"))
|
||||
assigns = assign(assigns, :textarea_id, if(assigns.reply_to, do: "reply-textarea", else: "post-textarea"))
|
||||
|
||||
~H"""
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={@current_user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- Reply context -->
|
||||
<div :if={@reply_to} class="mb-2 text-sm text-muted-foreground">
|
||||
Replying to <span class="text-primary">@<%= @reply_to.user.username %></span>
|
||||
</div>
|
||||
|
||||
<form id={@form_id} phx-submit={@submit_event}>
|
||||
<textarea
|
||||
name="body"
|
||||
phx-keyup={@update_event}
|
||||
phx-hook="PasteImage"
|
||||
id={@textarea_id}
|
||||
placeholder={@placeholder}
|
||||
rows="3"
|
||||
class="flex min-h-[60px] w-full border-0 bg-transparent px-0 py-0 text-lg placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none focus:ring-0 resize-none"
|
||||
><%= @body %></textarea>
|
||||
|
||||
<!-- Media Previews -->
|
||||
<div :if={length(@uploaded_files) > 0 || length(@uploads.media.entries) > 0} class="grid grid-cols-2 gap-2 mt-3">
|
||||
<!-- Uploaded files (GIFs, pasted images) -->
|
||||
<div
|
||||
:for={{url, _type} <- Enum.with_index(@uploaded_files)}
|
||||
class="relative overflow-hidden rounded-lg aspect-video bg-muted"
|
||||
>
|
||||
<img src={elem(url, 0)} alt="Preview" class="object-cover w-full h-full" />
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_media"
|
||||
phx-value-index={elem(url, 1)}
|
||||
class="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-full text-white"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- LiveView uploads -->
|
||||
<div
|
||||
:for={entry <- @uploads.media.entries}
|
||||
class="relative overflow-hidden rounded-lg aspect-video bg-muted"
|
||||
>
|
||||
<.live_img_preview entry={entry} class="object-cover w-full h-full" />
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
class="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-full text-white"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="relative flex items-center space-x-1">
|
||||
<!-- Image/Video Upload -->
|
||||
<label class="flex items-center justify-center p-2 transition-colors rounded-full cursor-pointer hover:bg-accent text-primary">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<.live_file_input upload={@uploads.media} class="hidden" />
|
||||
</label>
|
||||
|
||||
<!-- GIF Button -->
|
||||
<button
|
||||
type="button"
|
||||
phx-click="open_giphy"
|
||||
class="flex items-center justify-center p-2 transition-colors rounded-full hover:bg-accent text-primary"
|
||||
>
|
||||
<span class="text-sm font-bold">GIF</span>
|
||||
</button>
|
||||
|
||||
<!-- Emoji Button -->
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_emoji_picker"
|
||||
class="flex items-center justify-center p-2 transition-colors rounded-full hover:bg-accent text-primary"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={[
|
||||
"text-sm",
|
||||
@char_count > 280 && "text-destructive",
|
||||
@char_count <= 280 && "text-muted-foreground"
|
||||
]}>
|
||||
<%= @char_count %> / 280
|
||||
</span>
|
||||
|
||||
<button
|
||||
:if={@show_cancel}
|
||||
type="button"
|
||||
phx-click={@cancel_event}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors rounded-md hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<.ui_button type="submit" disabled={@char_count == 0 || @char_count > 280}>
|
||||
Post
|
||||
</.ui_button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
283
lib/malarkey_web/components/posts.ex
Normal file
283
lib/malarkey_web/components/posts.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule MalarkeyWeb.Components.Posts do
|
||||
@moduledoc """
|
||||
Reusable post display components.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Media
|
||||
import MalarkeyWeb.Components.Avatar
|
||||
|
||||
alias Malarkey.Social
|
||||
|
||||
# Import for verified routes
|
||||
use MalarkeyWeb, :verified_routes
|
||||
|
||||
attr :post, :map, required: true
|
||||
attr :current_user, :map, required: true
|
||||
attr :dom_id, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def post_card(assigns) do
|
||||
assigns = assign(assigns, :card_class, Enum.join(["transition-all duration-300 ease-out hover:shadow-md animate-slide-in cursor-pointer", assigns[:class] || ""], " "))
|
||||
|
||||
~H"""
|
||||
<.ui_card
|
||||
id={@dom_id}
|
||||
class={@card_class}
|
||||
phx-hook="PostCard"
|
||||
data-post-url={~p"/posts/#{@post.id}"}
|
||||
>
|
||||
<.ui_card_content class="pt-6">
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="block transition-opacity hover:opacity-80">
|
||||
<.avatar user={@post.user} size="lg" />
|
||||
</.link>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="font-semibold transition-colors text-foreground hover:underline">
|
||||
<%= @post.user.display_name || @post.user.username %>
|
||||
</.link>
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="transition-colors text-muted-foreground hover:underline">
|
||||
@<%= @post.user.username %>
|
||||
</.link>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<%= Timex.format!(@post.inserted_at, "{relative}", :relative) %>
|
||||
</span>
|
||||
</div>
|
||||
<p :if={@post.body && String.trim(@post.body) != "" && String.trim(@post.body) != " "} class="mt-2 whitespace-pre-wrap text-foreground">
|
||||
<%= @post.body %>
|
||||
</p>
|
||||
|
||||
<!-- Media Display -->
|
||||
<.post_media
|
||||
:if={length(@post.media_urls || []) > 0}
|
||||
media_urls={@post.media_urls}
|
||||
media_types={@post.media_types || []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.ui_card_content>
|
||||
|
||||
<!-- Post Actions - outside the link -->
|
||||
<div class="flex items-center gap-6 mt-4 px-4 pb-2">
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="open_reply_modal"
|
||||
phx-value-id={@post.id}
|
||||
class="gap-2 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.replies_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={
|
||||
if reposted_by_user?(@post, @current_user),
|
||||
do: "delete_repost",
|
||||
else: "repost"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
reposted_by_user?(@post, @current_user) && "text-green-600",
|
||||
!reposted_by_user?(@post, @current_user) && "text-muted-foreground hover:text-green-600"
|
||||
]}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.reposts_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={
|
||||
if liked_by_user?(@post, @current_user),
|
||||
do: "unlike_post",
|
||||
else: "like_post"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
liked_by_user?(@post, @current_user) && "text-red-500 hover:text-red-600",
|
||||
!liked_by_user?(@post, @current_user) &&
|
||||
"text-muted-foreground hover:text-red-500"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
class={[
|
||||
"w-4 h-4",
|
||||
liked_by_user?(@post, @current_user) && "fill-current"
|
||||
]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.likes_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button variant="ghost" size="sm" class="text-muted-foreground hover:text-primary">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
:if={@post.user_id == @current_user.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="delete_post"
|
||||
phx-value-id={@post.id}
|
||||
data-confirm="Are you sure you want to delete this post?"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</.ui_button>
|
||||
</div>
|
||||
</.ui_card>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :reply, :map, required: true
|
||||
attr :current_user, :map, required: true
|
||||
attr :level, :integer, default: 0
|
||||
attr :parent_post, :map, default: nil
|
||||
|
||||
def threaded_reply(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"relative",
|
||||
@level > 0 && "ml-12"
|
||||
]}>
|
||||
<!-- Thread line connector -->
|
||||
<div :if={@level > 0} class="absolute left-0 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700 -ml-6"></div>
|
||||
|
||||
<div class="flex space-x-3 py-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="block transition-opacity hover:opacity-80">
|
||||
<.avatar user={@reply.user} size="md" />
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="font-semibold transition-colors hover:underline">
|
||||
<%= @reply.user.display_name || @reply.user.username %>
|
||||
</.link>
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="text-gray-500 transition-colors hover:underline">
|
||||
@<%= @reply.user.username %>
|
||||
</.link>
|
||||
<span class="text-gray-500">·</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
<%= Timex.format!(@reply.inserted_at, "{relative}", :relative) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :if={@parent_post} class="text-sm text-gray-500 mt-1">
|
||||
Replying to <.link navigate={~p"/#{@parent_post.user.username}"} class="text-blue-500 hover:underline">@<%= @parent_post.user.username %></.link>
|
||||
</div>
|
||||
|
||||
<p :if={@reply.body && String.trim(@reply.body) != "" && String.trim(@reply.body) != " "} class="mt-2 whitespace-pre-wrap">
|
||||
<%= @reply.body %>
|
||||
</p>
|
||||
|
||||
<!-- Reply Actions -->
|
||||
<div class="flex items-center gap-6 mt-3">
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="open_reply_modal"
|
||||
phx-value-id={@reply.id}
|
||||
class="gap-2 text-gray-500 hover:text-blue-500"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @reply.replies_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={if liked_by_user?(@reply, @current_user), do: "unlike_post", else: "like_post"}
|
||||
phx-value-id={@reply.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
liked_by_user?(@reply, @current_user) && "text-red-500 hover:text-red-600",
|
||||
!liked_by_user?(@reply, @current_user) && "text-gray-500 hover:text-red-500"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
class={["w-4 h-4", liked_by_user?(@reply, @current_user) && "fill-current"]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @reply.likes_count %></span>
|
||||
</.ui_button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions that need to be available
|
||||
defp reposted_by_user?(post, user) do
|
||||
Social.reposted_by_user?(user.id, post.id)
|
||||
end
|
||||
|
||||
defp liked_by_user?(post, user) do
|
||||
Social.liked_by_user?(user.id, post.id)
|
||||
end
|
||||
end
|
||||
368
lib/malarkey_web/components/ui.ex
Normal file
368
lib/malarkey_web/components/ui.ex
Normal file
@@ -0,0 +1,368 @@
|
||||
defmodule MalarkeyWeb.Components.UI do
|
||||
@moduledoc """
|
||||
Shadcn-inspired UI components for Malarkey.
|
||||
|
||||
These components follow the shadcn/ui design system adapted for Phoenix LiveView.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a button with shadcn styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.ui_button>Click me</.ui_button>
|
||||
<.ui_button variant="outline">Outlined</.ui_button>
|
||||
<.ui_button variant="ghost">Ghost</.ui_button>
|
||||
<.ui_button size="lg">Large button</.ui_button>
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default outline ghost destructive)
|
||||
attr :size, :string, default: "default", values: ~w(default sm lg icon)
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global, include: ~w(disabled type phx-click phx-disable-with phx-value-id phx-value-content)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
class={[
|
||||
# Base styles
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
# Variant styles
|
||||
variant_classes(@variant),
|
||||
# Size styles
|
||||
size_classes(@size),
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp variant_classes("default"),
|
||||
do: "bg-primary text-primary-foreground shadow hover:bg-primary/90"
|
||||
|
||||
defp variant_classes("outline"),
|
||||
do:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
defp variant_classes("ghost"), do: "hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
defp variant_classes("destructive"),
|
||||
do: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90"
|
||||
|
||||
defp size_classes("default"), do: "h-9 px-4 py-2"
|
||||
defp size_classes("sm"), do: "h-8 rounded-md px-3 text-xs"
|
||||
defp size_classes("lg"), do: "h-10 rounded-md px-8"
|
||||
defp size_classes("icon"), do: "h-9 w-9"
|
||||
|
||||
@doc """
|
||||
Renders a shadcn-styled input field.
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
attr :type, :string, default: "text"
|
||||
attr :class, :string, default: nil
|
||||
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
slot :inner_block
|
||||
|
||||
def ui_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> ui_input()
|
||||
end
|
||||
|
||||
def ui_input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={[
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<label
|
||||
:if={@label}
|
||||
for={@id}
|
||||
class="text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp ui_input_error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-1 flex gap-1 text-sm leading-6 text-destructive">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5 flex-none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a shadcn-styled card.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={[
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card header.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_header(assigns) do
|
||||
~H"""
|
||||
<div class={["flex flex-col space-y-1.5 p-6", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card title.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_title(assigns) do
|
||||
~H"""
|
||||
<h3 class={["font-semibold leading-none tracking-tight", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h3>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card description.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_description(assigns) do
|
||||
~H"""
|
||||
<p class={["text-sm text-muted-foreground", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card content area.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_content(assigns) do
|
||||
~H"""
|
||||
<div class={["p-6 pt-0", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a separator.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
|
||||
|
||||
def ui_separator(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"shrink-0 bg-border",
|
||||
@orientation == "horizontal" && "h-[1px] w-full",
|
||||
@orientation == "vertical" && "h-full w-[1px]",
|
||||
@class
|
||||
]}
|
||||
role="separator"
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an alert with shadcn styling.
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default destructive)
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_alert(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm",
|
||||
@variant == "default" && "bg-background text-foreground",
|
||||
@variant == "destructive" &&
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
@class
|
||||
]}
|
||||
role="alert"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a badge with shadcn styling.
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default secondary outline destructive)
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_badge(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
badge_variant_classes(@variant),
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp badge_variant_classes("default"),
|
||||
do: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80"
|
||||
|
||||
defp badge_variant_classes("secondary"),
|
||||
do: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
|
||||
defp badge_variant_classes("outline"), do: "text-foreground"
|
||||
|
||||
defp badge_variant_classes("destructive"),
|
||||
do: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80"
|
||||
|
||||
# Helper function to translate errors
|
||||
defp translate_error({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end
|
||||
end
|
||||
27
lib/malarkey_web/controllers/auth_controller.ex
Normal file
27
lib/malarkey_web/controllers/auth_controller.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule MalarkeyWeb.AuthController do
|
||||
use MalarkeyWeb, :controller
|
||||
plug Ueberauth
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias MalarkeyWeb.UserAuth
|
||||
|
||||
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
|
||||
case Accounts.get_or_create_oauth_user(auth.provider, auth) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Successfully authenticated.")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Failed to authenticate.")
|
||||
|> redirect(to: ~p"/users/log_in")
|
||||
end
|
||||
end
|
||||
|
||||
def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
|
||||
conn
|
||||
|> put_flash(:error, "Failed to authenticate.")
|
||||
|> redirect(to: ~p"/users/log_in")
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,19 @@
|
||||
defmodule MalarkeyWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use MalarkeyWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/malarkey_web/controllers/error/404.html.heex
|
||||
# * lib/malarkey_web/controllers/error/500.html.heex
|
||||
# * lib/malarkey_web/controllers/error_html/404.html.heex
|
||||
# * lib/malarkey_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error/*"
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
defmodule MalarkeyWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
defmodule MalarkeyWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use MalarkeyWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<div class="fixed inset-y-0 right-0 left-[40rem] hidden lg:block xl:left-[50rem]">
|
||||
<.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 w-full h-full"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
@@ -37,21 +38,21 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:py-28 sm:px-6 lg:px-8 xl:py-32 xl:px-28">
|
||||
<div class="max-w-xl mx-auto lg:mx-0">
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="flex items-center mt-10 text-sm font-semibold leading-6 text-brand">
|
||||
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="ml-3 rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6">
|
||||
v1.7
|
||||
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
|
||||
v{Application.spec(:phoenix, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
<p class="mt-4 text-[2rem] font-semibold leading-10 tracking-tighter text-zinc-900">
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 text-base leading-7 text-zinc-600">
|
||||
@@ -59,15 +60,15 @@
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="grid grid-cols-1 mt-10 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="relative px-6 py-4 text-sm font-semibold leading-6 group rounded-2xl text-zinc-900 sm:py-6"
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 transition rounded-2xl bg-zinc-50 group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="w-6 h-6">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
@@ -82,12 +83,12 @@
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="relative px-6 py-4 text-sm font-semibold leading-6 group rounded-2xl text-zinc-900 sm:py-6"
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 transition rounded-2xl bg-zinc-50 group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-6 h-6">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
@@ -99,13 +100,13 @@
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix/blob/v1.7/CHANGELOG.md"
|
||||
class="relative px-6 py-4 text-sm font-semibold leading-6 group rounded-2xl text-zinc-900 sm:py-6"
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 transition rounded-2xl bg-zinc-50 group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="w-6 h-6">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="#18181B"
|
||||
@@ -129,7 +130,7 @@
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 mt-10 text-sm leading-6 gap-y-4 text-zinc-700 sm:grid-cols-2">
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://twitter.com/elixirphoenix"
|
||||
@@ -138,7 +139,7 @@
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
|
||||
</svg>
|
||||
@@ -153,26 +154,11 @@
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixir-slackin.herokuapp.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M3.95 9.85a1.47 1.47 0 1 1-2.94 0 1.47 1.47 0 0 1 1.47-1.472h1.47v1.471Zm.735 0a1.47 1.47 0 1 1 2.94 0v3.678a1.47 1.47 0 1 1-2.94 0V9.85ZM6.156 3.942a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 1 1 2.94 0v1.472h-1.47Zm0 .747c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H2.47A1.47 1.47 0 0 1 1 6.16 1.47 1.47 0 0 1 2.47 4.69h3.686ZM12.048 6.16a1.47 1.47 0 1 1 2.94 0 1.47 1.47 0 0 1-1.47 1.472h-1.47V6.16Zm-.735 0a1.47 1.47 0 1 1-2.94 0V2.47a1.47 1.47 0 1 1 2.94 0v3.69ZM9.843 12.057c.813 0 1.47.657 1.47 1.471a1.47 1.47 0 1 1-2.94 0v-1.471h1.47Zm0-.736a1.47 1.47 0 0 1-1.47-1.472 1.47 1.47 0 0 1 1.47-1.471h3.686c.813 0 1.47.658 1.47 1.471a1.47 1.47 0 0 1-1.47 1.472H9.843Z" />
|
||||
</svg>
|
||||
Join our Slack channel
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
@@ -183,7 +169,7 @@
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
@@ -207,7 +193,7 @@
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
@@ -216,13 +202,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/getting-started/elixir/"
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="w-4 h-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
|
||||
33
lib/malarkey_web/controllers/user_confirmation_controller.ex
Normal file
33
lib/malarkey_web/controllers/user_confirmation_controller.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationController do
|
||||
use MalarkeyWeb, :controller
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def edit(conn, %{"token" => token}) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "User confirmed successfully.")
|
||||
|> redirect(to: ~p"/")
|
||||
|
||||
:error ->
|
||||
# If there is a current user and the account was already confirmed,
|
||||
# then odds are that the confirmation link was already visited, either
|
||||
# by some automation or by the user themselves, so we redirect without
|
||||
# a warning message.
|
||||
case conn.assigns do
|
||||
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
|
||||
redirect(conn, to: ~p"/")
|
||||
|
||||
%{} ->
|
||||
conn
|
||||
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"token" => token}) do
|
||||
edit(conn, %{"token" => token})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationInstructionsController do
|
||||
use MalarkeyWeb, :controller
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, :new, page_title: "Resend confirmation instructions")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&url(~p"/users/confirm/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system and it has not been confirmed yet, " <>
|
||||
"you will receive an email with instructions shortly."
|
||||
)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
@@ -4,27 +4,11 @@ defmodule MalarkeyWeb.UserSessionController do
|
||||
alias Malarkey.Accounts
|
||||
alias MalarkeyWeb.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "registered"} = params) do
|
||||
create(conn, params, "Account created successfully!")
|
||||
end
|
||||
|
||||
def create(conn, %{"_action" => "password_updated"} = params) do
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
UserAuth.log_in_user(conn, user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|
||||
@@ -7,16 +7,22 @@ defmodule MalarkeyWeb.Endpoint do
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_malarkey_key",
|
||||
signing_salt: "nOkCv+jj",
|
||||
signing_salt: "Iwb1Vb0s",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/uploads" the static files from "priv/static/uploads" directory.
|
||||
plug Plug.Static,
|
||||
at: "/uploads",
|
||||
from: Path.expand("./priv/static/uploads"),
|
||||
gzip: false,
|
||||
only: ~w()
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phx.digest
|
||||
# when deploying your static files in production.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :malarkey,
|
||||
|
||||
@@ -2,10 +2,11 @@ defmodule MalarkeyWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||
that you can use in your application. To use this Gettext backend module,
|
||||
call `use Gettext` and pass it as an option:
|
||||
|
||||
import MalarkeyWeb.Gettext
|
||||
use Gettext, backend: MalarkeyWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
@@ -20,5 +21,5 @@ defmodule MalarkeyWeb.Gettext do
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :malarkey
|
||||
use Gettext.Backend, otp_app: :malarkey
|
||||
end
|
||||
|
||||
61
lib/malarkey_web/live/explore_live/index.ex
Normal file
61
lib/malarkey_web/live/explore_live/index.ex
Normal file
@@ -0,0 +1,61 @@
|
||||
defmodule MalarkeyWeb.ExploreLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Explore")
|
||||
|> assign(:search_query, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
{:noreply, assign(socket, :search_query, query)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h1 class="text-xl font-bold">Explore</h1>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Malarkey"
|
||||
value={@search_query}
|
||||
phx-change="search"
|
||||
class="w-full px-4 py-3 rounded-full bg-gray-100 dark:bg-gray-800 border-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p>Try searching for people, topics, or keywords</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
49
lib/malarkey_web/live/notification_live/index.ex
Normal file
49
lib/malarkey_web/live/notification_live/index.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule MalarkeyWeb.NotificationLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Notifications")
|
||||
|> assign(:notifications, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h1 class="text-xl font-bold">Notifications</h1>
|
||||
</div>
|
||||
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<p>No notifications yet</p>
|
||||
<p class="text-sm mt-2">
|
||||
When someone likes, reposts, or replies to your posts, you'll see it here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -1,95 +0,0 @@
|
||||
defmodule MalarkeyWeb.PostLive.FormComponent do
|
||||
use MalarkeyWeb, :live_component
|
||||
require Logger
|
||||
|
||||
alias Malarkey.Timeline
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.header>
|
||||
<%= @title %>
|
||||
<:subtitle>Dazzle us, <%= @user.fullname %>!!</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="post-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.input field={{f, :body}} type="textarea" label="body" />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Saving...">Save Post</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{post: post} = assigns, socket) do
|
||||
changeset = Timeline.change_post(post)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"post" => post_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.post
|
||||
|> Timeline.change_post(post_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"post" => post_params}, socket) do
|
||||
save_post(socket, socket.assigns.action, post_params)
|
||||
end
|
||||
|
||||
defp save_post(socket, :like, _post_params) do
|
||||
case Timeline.add_like(socket.assigns.current_user, socket.assigns.post) do
|
||||
{:ok, _post} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Post updated successfully")
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_post(socket, :edit, post_params) do
|
||||
case Timeline.update_post(socket.assigns.post, post_params) do
|
||||
{:ok, _post} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Post updated successfully")
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_post(socket, :new, post_params) do
|
||||
case Timeline.create_post(socket.assigns.user, post_params) do
|
||||
{:ok, _post} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Post created successfully")
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,82 +0,0 @@
|
||||
defmodule MalarkeyWeb.PostLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
require Logger
|
||||
alias Malarkey.Timeline
|
||||
alias Malarkey.Timeline.Post
|
||||
on_mount MalarkeyWeb.UserLiveAuth
|
||||
|
||||
@impl true
|
||||
@spec mount(any, any, Phoenix.LiveView.Socket.t()) ::
|
||||
{:ok, map, [{:temporary_assigns, [...]}, ...]}
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Timeline.subscribe()
|
||||
{:ok, assign(socket, :posts, list_posts()), temporary_assigns: [posts: []]}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :like, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Edit Post")
|
||||
|> assign(:post, Timeline.get_post!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Edit Post")
|
||||
|> assign(:post, Timeline.get_post!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "More Malarkey")
|
||||
|> assign(:post, %Post{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Posts")
|
||||
|> assign(:post, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("like", %{"id" => id}, socket) do
|
||||
post = Timeline.get_post!(id)
|
||||
{:ok, _} = Timeline.add_like(socket.assigns.current_user, post)
|
||||
|
||||
{:noreply, assign(socket, :posts, list_posts())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("repost", %{"id" => id}, socket) do
|
||||
post = Timeline.get_post!(id)
|
||||
{:ok, _} = Timeline.add_repost(socket.assigns.current_user, post)
|
||||
|
||||
{:noreply, assign(socket, :posts, list_posts())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
post = Timeline.get_post!(id)
|
||||
{:ok, _} = Timeline.delete_post(post)
|
||||
|
||||
{:noreply, assign(socket, :posts, list_posts())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:post_created, post}, socket) do
|
||||
{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:post_updated, post}, socket) do
|
||||
{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}
|
||||
end
|
||||
|
||||
defp list_posts do
|
||||
Timeline.list_posts()
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
<.header>
|
||||
Timeline
|
||||
<:actions>
|
||||
<.link patch={(!!@current_user && ~p"/posts/new") || "/users/log_in"}>
|
||||
<button class="inline-flex items-center px-3 py-2 text-sm font-semibold leading-6 text-white rounded-lg bg-brand phx-submit-loading:opacity-75 hover:bg-zinc-700 active:text-white/80">
|
||||
<%= Heroicons.icon("plus-circle", type: "outline", class: "w-5 h-5 stroke-current mr-1") %>
|
||||
<span>Add Malarkey</span>
|
||||
</button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div id="posts" phx-update="prepend">
|
||||
<%= if !!@current_user do %>
|
||||
<h1 id="user-greeting">Hi, <%= !!@current_user.fullname %></h1>
|
||||
<% else %>
|
||||
<h1 id="anonymous-greeting">Hello, Sailor!</h1>
|
||||
<% end %>
|
||||
<%= for post <- @posts do %>
|
||||
<.live_component module={MalarkeyWeb.PostLive.PostComponent} id={post.id} , post={post} />
|
||||
<% end %>
|
||||
</div>
|
||||
<.modal
|
||||
:if={@live_action in [:new, :edit]}
|
||||
id="post-modal"
|
||||
show
|
||||
on_cancel={JS.navigate(~p"/posts")}
|
||||
>
|
||||
<.live_component
|
||||
module={MalarkeyWeb.PostLive.FormComponent}
|
||||
id={@post.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
post={@post}
|
||||
navigate={~p"/posts"}
|
||||
user={@current_user}
|
||||
/>
|
||||
</.modal>
|
||||
@@ -1,102 +0,0 @@
|
||||
defmodule MalarkeyWeb.PostLive.PostComponent do
|
||||
use MalarkeyWeb, :live_component
|
||||
alias Malarkey.Timeline
|
||||
|
||||
@impl true
|
||||
@spec render(any) :: Phoenix.LiveView.Rendered.t()
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-xl mx-auto my-6">
|
||||
<article class="flex flex-wrap items-start p-2 border-t border-b border-gray-400 cursor-pointer hover:bg-gray-100">
|
||||
<img
|
||||
src="https://joeschmoe.io/api/v1/random?q=#{@post.id}"
|
||||
class="w-12 h-12 mr-3 rounded-full"
|
||||
/>
|
||||
<div class="flex flex-wrap items-start justify-start flex-1">
|
||||
<div class="flex items-center flex-1">
|
||||
<div class="flex items-center flex-1">
|
||||
<h3 class="mr-2 font-bold hover:underline">
|
||||
<a href="#"><%= @post.user.fullname %></a>
|
||||
</h3>
|
||||
<span class="mr-2">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="#1da1f2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="Verified account"
|
||||
class=""
|
||||
>
|
||||
<g>
|
||||
<path d="M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mr-1 text-sm text-gray-600">@<%= @post.user.username %></span>
|
||||
<span class="mr-1 text-sm text-gray-600">·</span>
|
||||
<span class="text-sm text-gray-600">Apr 7</span>
|
||||
</div>
|
||||
<div class="text-gray-600">
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-center w-6 h-6 bg-transparent rounded-full hover:bg-blue-200 hover:text-blue-600"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-3 h-3 fill-current">
|
||||
<g>
|
||||
<path d="M20.207 8.147c-.39-.39-1.023-.39-1.414 0L12 14.94 5.207 8.147c-.39-.39-1.023-.39-1.414 0-.39.39-.39 1.023 0 1.414l7.5 7.5c.195.196.45.294.707.294s.512-.098.707-.293l7.5-7.5c.39-.39.39-1.022 0-1.413z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<p class="my-1"><%= @post.body %></p>
|
||||
<%= if false do %>
|
||||
<div class="rounded-lg">
|
||||
<img
|
||||
src="https://www.fillmurray.com/640/360"
|
||||
class="object-cover w-full h-64 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center mr-8 text-gray-600 hover:text-blue-500">
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full hover:bg-blue-200 hover:text-blue-500"
|
||||
>
|
||||
<%= Heroicons.icon("chat-bubble-oval-left", type: "outline", class: "w-5 h-5") %>
|
||||
</a>
|
||||
<span class="ml-1">1.5K</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mr-8 text-gray-600 hover:text-green-500">
|
||||
<.link phx-click={JS.push("repost", value: %{id: @post.id})}>
|
||||
<%= Heroicons.icon("arrow-path-rounded-square", type: "outline", class: "w-5 h-5") %>
|
||||
</.link>
|
||||
<span class="ml-1"><%= length(@post.reposted_by) %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mr-6 text-gray-600 hover:text-red-500">
|
||||
<.link phx-click={JS.push("like", value: %{id: @post.id})}>
|
||||
<%= Heroicons.icon("heart", type: "outline", class: "w-5 h-5") %>
|
||||
</.link>
|
||||
<span class="ml-1"><%= length(@post.liked_by) %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mr-6 text-gray-600 hover:text-red-500 pull">
|
||||
<.link patch={~p"/posts/#{@post}/edit"}>
|
||||
<%= Heroicons.icon("pencil-square", type: "outline", class: "w-5 h-5") %>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -1,21 +1,343 @@
|
||||
defmodule MalarkeyWeb.PostLive.Show do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
alias Malarkey.Timeline
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Social
|
||||
import MalarkeyWeb.Components.Posts
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Social.get_post(id) do
|
||||
nil ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Post not found")
|
||||
|> push_navigate(to: ~p"/")}
|
||||
|
||||
post ->
|
||||
threaded_replies = Social.get_threaded_replies(id)
|
||||
direct_replies = Map.get(threaded_replies, post.id, [])
|
||||
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Malarkey.PubSub, "post:#{id}")
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:post, post)
|
||||
|> assign(:page_title, "Post")
|
||||
|> assign(:reply_content, "")
|
||||
|> assign(:char_count, 0)
|
||||
|> assign(:threaded_replies, threaded_replies)
|
||||
|> assign(:direct_replies, direct_replies)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:post, Timeline.get_post!(id))}
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Post"
|
||||
defp page_title(:edit), do: "Edit Post"
|
||||
@impl true
|
||||
def handle_event("update_reply", %{"reply_content" => content}, socket) do
|
||||
char_count = String.length(content)
|
||||
{:noreply, assign(socket, reply_content: content, char_count: char_count)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("post_reply", _, socket) do
|
||||
attrs = %{
|
||||
body: socket.assigns.reply_content,
|
||||
user_id: socket.assigns.current_user.id,
|
||||
reply_to_id: socket.assigns.post.id
|
||||
}
|
||||
|
||||
case Social.create_post(attrs) do
|
||||
{:ok, _reply} ->
|
||||
# Reload threaded replies
|
||||
threaded_replies = Social.get_threaded_replies(socket.assigns.post.id)
|
||||
direct_replies = Map.get(threaded_replies, socket.assigns.post.id, [])
|
||||
updated_post = Social.get_post!(socket.assigns.post.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Reply posted successfully")
|
||||
|> assign(reply_content: "", char_count: 0)
|
||||
|> assign(:threaded_replies, threaded_replies)
|
||||
|> assign(:direct_replies, direct_replies)
|
||||
|> assign(:post, updated_post)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
errors = changeset_errors(changeset)
|
||||
{:noreply, put_flash(socket, :error, "Failed to post reply: #{errors}")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("like_post", %{"id" => id}, socket) do
|
||||
case Social.create_like(%{user_id: socket.assigns.current_user.id, post_id: id}) do
|
||||
{:ok, _like} ->
|
||||
updated_post = Social.get_post!(id)
|
||||
{:noreply, assign(socket, :post, updated_post)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to like post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlike_post", %{"id" => id}, socket) do
|
||||
case Social.delete_like(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
updated_post = Social.get_post!(id)
|
||||
{:noreply, assign(socket, :post, updated_post)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlike post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("repost", %{"id" => id}, socket) do
|
||||
case Social.create_repost(socket.assigns.current_user.id, id) do
|
||||
{:ok, _repost} ->
|
||||
{:noreply, put_flash(socket, :info, "Reposted successfully")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to repost")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_repost", %{"id" => id}, socket) do
|
||||
case Social.delete_repost(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, put_flash(socket, :info, "Repost removed")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove repost")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:like_added, _like}, socket) do
|
||||
updated_post = Social.get_post!(socket.assigns.post.id)
|
||||
{:noreply, assign(socket, :post, updated_post)}
|
||||
end
|
||||
|
||||
def handle_info({:like_removed, _like}, socket) do
|
||||
updated_post = Social.get_post!(socket.assigns.post.id)
|
||||
{:noreply, assign(socket, :post, updated_post)}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<.link navigate={~p"/"} class="text-blue-500 hover:underline">
|
||||
← Back to Timeline
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<!-- Main Post -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={@post.user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="font-bold">
|
||||
<%= @post.user.display_name || @post.user.username %>
|
||||
</span>
|
||||
<span class="text-gray-500">@<%= @post.user.username %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="text-xl whitespace-pre-wrap"><%= @post.body %></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-gray-500 text-sm">
|
||||
<%= Timex.format!(@post.inserted_at, "{h12}:{m} {AM} · {Mshort} {D}, {YYYY}") %>
|
||||
</div>
|
||||
|
||||
<!-- Post Stats -->
|
||||
<div class="flex items-center space-x-4 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="font-bold"><%= @post.reposts_count %></span>
|
||||
<span class="text-gray-500"> Reposts</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold"><%= @post.likes_count %></span>
|
||||
<span class="text-gray-500"> Likes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="flex items-center justify-around mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button class="text-gray-500 hover:text-blue-500 p-2">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
phx-click={
|
||||
if @post.user_id == @current_user.id, do: "delete_repost", else: "repost"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class="text-gray-500 hover:text-green-500 p-2"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
phx-click={
|
||||
if liked_by_user?(@post, @current_user), do: "unlike_post", else: "like_post"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class={[
|
||||
"p-2",
|
||||
liked_by_user?(@post, @current_user) && "text-red-500",
|
||||
!liked_by_user?(@post, @current_user) && "text-gray-500 hover:text-red-500"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
class={[
|
||||
"w-6 h-6",
|
||||
liked_by_user?(@post, @current_user) && "fill-current"
|
||||
]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="text-gray-500 hover:text-blue-500 p-2">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Composer -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={@current_user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
phx-keyup="update_reply"
|
||||
name="reply_content"
|
||||
class="w-full border-none focus:ring-0 resize-none text-lg"
|
||||
placeholder="Post your reply"
|
||||
rows="3"
|
||||
><%= @reply_content %></textarea>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex space-x-2">
|
||||
<!-- Media upload buttons would go here -->
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class={[
|
||||
"text-sm",
|
||||
@char_count > 280 && "text-red-500",
|
||||
@char_count > 260 && @char_count <= 280 && "text-yellow-500",
|
||||
@char_count <= 260 && "text-gray-500"
|
||||
]}>
|
||||
<%= if @char_count > 0, do: "#{@char_count}/280", else: "" %>
|
||||
</span>
|
||||
<button
|
||||
phx-click="post_reply"
|
||||
disabled={@char_count == 0 || @char_count > 280}
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-full font-bold hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<div id="replies" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<%= for reply <- @direct_replies do %>
|
||||
<div class="p-4">
|
||||
<.threaded_reply
|
||||
reply={reply}
|
||||
current_user={@current_user}
|
||||
level={0}
|
||||
parent_post={@post}
|
||||
/>
|
||||
<%= render_nested_replies(assigns, reply, 1) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_nested_replies(assigns, parent_reply, level) do
|
||||
replies = Map.get(assigns.threaded_replies, parent_reply.id, [])
|
||||
|
||||
assigns = assign(assigns, :nested_replies, replies)
|
||||
assigns = assign(assigns, :level, level)
|
||||
assigns = assign(assigns, :parent_reply, parent_reply)
|
||||
|
||||
~H"""
|
||||
<%= for reply <- @nested_replies do %>
|
||||
<.threaded_reply
|
||||
reply={reply}
|
||||
current_user={@current_user}
|
||||
level={@level}
|
||||
parent_post={@parent_reply}
|
||||
/>
|
||||
<%= render_nested_replies(assigns, reply, @level + 1) %>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp changeset_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
||||
|> Enum.map(fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end)
|
||||
|> Enum.join("; ")
|
||||
end
|
||||
|
||||
defp liked_by_user?(post, user) do
|
||||
Social.liked_by_user?(user.id, post.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<.header>
|
||||
Post <%= @post.id %>
|
||||
<:subtitle>This is a post record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.link patch={~p"/posts/#{@post}/show/edit"} phx-click={JS.push_focus()}>
|
||||
<.button>Edit post</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Username"><%= @post.user.username %></:item>
|
||||
<:item title="Body"><%= @post.body %></:item>
|
||||
<:item title="Likes count"><%= @post.likes_count %></:item>
|
||||
<:item title="Repost count"><%= @post.repost_count %></:item>
|
||||
</.list>
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
|
||||
<.modal :if={@live_action == :edit} id="post-modal" show on_cancel={JS.patch(~p"/posts/#{@post}")}>
|
||||
<.live_component
|
||||
module={MalarkeyWeb.PostLive.FormComponent}
|
||||
id={@post.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
post={@post}
|
||||
navigate={~p"/posts/#{@post}"}
|
||||
/>
|
||||
</.modal>
|
||||
124
lib/malarkey_web/live/profile_live/followers.ex
Normal file
124
lib/malarkey_web/live/profile_live/followers.ex
Normal file
@@ -0,0 +1,124 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Followers do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
followers = Social.list_followers(user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username} - Followers")
|
||||
|> stream(:followers, followers)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", %{"id" => id}, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", %{"id" => id}, socket) do
|
||||
case Social.delete_follow(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<.link class="text-blue-500 hover:underline">
|
||||
← Back
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold mt-2">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="font-bold text-lg">Followers</h2>
|
||||
</div>
|
||||
|
||||
<div id="followers" phx-update="stream">
|
||||
<div
|
||||
:for={{dom_id, follower} <- @streams.followers}
|
||||
id={dom_id}
|
||||
class="border-b border-gray-200 dark:border-gray-700 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex space-x-3 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={follower} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold">
|
||||
<%= follower.display_name || follower.username %>
|
||||
</div>
|
||||
<div class="text-gray-500">@<%= follower.username %></div>
|
||||
<%= if follower.bio do %>
|
||||
<p class="text-sm mt-1 line-clamp-2"><%= follower.bio %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @current_user.id != follower.id do %>
|
||||
<%= if following?(@current_user, follower) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
phx-value-id={follower.id}
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
phx-value-id={follower.id}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp following?(current_user, other_user) do
|
||||
Social.following?(current_user.id, other_user.id)
|
||||
end
|
||||
end
|
||||
124
lib/malarkey_web/live/profile_live/following.ex
Normal file
124
lib/malarkey_web/live/profile_live/following.ex
Normal file
@@ -0,0 +1,124 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Following do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
following = Social.list_following(user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username} - Following")
|
||||
|> stream(:following, following)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", %{"id" => id}, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", %{"id" => id}, socket) do
|
||||
case Social.delete_follow(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<.link class="text-blue-500 hover:underline">
|
||||
← Back
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold mt-2">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="font-bold text-lg">Following</h2>
|
||||
</div>
|
||||
|
||||
<div id="following" phx-update="stream">
|
||||
<div
|
||||
:for={{dom_id, followed_user} <- @streams.following}
|
||||
id={dom_id}
|
||||
class="border-b border-gray-200 dark:border-gray-700 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex space-x-3 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={followed_user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold">
|
||||
<%= followed_user.display_name || followed_user.username %>
|
||||
</div>
|
||||
<div class="text-gray-500">@<%= followed_user.username %></div>
|
||||
<%= if followed_user.bio do %>
|
||||
<p class="text-sm mt-1 line-clamp-2"><%= followed_user.bio %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @current_user.id != followed_user.id do %>
|
||||
<%= if following?(@current_user, followed_user) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
phx-value-id={followed_user.id}
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
phx-value-id={followed_user.id}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp following?(current_user, other_user) do
|
||||
Social.following?(current_user.id, other_user.id)
|
||||
end
|
||||
end
|
||||
329
lib/malarkey_web/live/profile_live/index.ex
Normal file
329
lib/malarkey_web/live/profile_live/index.ex
Normal file
@@ -0,0 +1,329 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Posts
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username}")
|
||||
|> assign(:active_tab, :posts)
|
||||
|> load_posts()}
|
||||
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(:active_tab, :posts)
|
||||
|> load_posts()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :replies, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :replies)
|
||||
|> load_replies()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :media, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :media)
|
||||
|> load_media()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :likes, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :likes)
|
||||
|> load_likes()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", _params, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: socket.assigns.profile_user.id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
updated_user = Accounts.get_user!(socket.assigns.profile_user.id)
|
||||
{:noreply, assign(socket, :profile_user, updated_user)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", _params, socket) do
|
||||
case Social.delete_follow(
|
||||
socket.assigns.current_user.id,
|
||||
socket.assigns.profile_user.id
|
||||
) do
|
||||
{:ok, _} ->
|
||||
updated_user = Accounts.get_user!(socket.assigns.profile_user.id)
|
||||
{:noreply, assign(socket, :profile_user, updated_user)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("like_post", %{"id" => id}, socket) do
|
||||
case Social.create_like(%{user_id: socket.assigns.current_user.id, post_id: id}) do
|
||||
{:ok, _like} ->
|
||||
{:noreply, reload_current_tab(socket)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to like post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlike_post", %{"id" => id}, socket) do
|
||||
case Social.delete_like(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, reload_current_tab(socket)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlike post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Profile Header -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-800">
|
||||
<!-- Header Section -->
|
||||
<%= if @profile_user.header_url do %>
|
||||
<div class="relative h-48 bg-gradient-to-r from-blue-400 to-purple-500">
|
||||
<img
|
||||
src={@profile_user.header_url}
|
||||
alt="Header"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="h-48 bg-gradient-to-r from-blue-400 to-purple-500"></div>
|
||||
<% end %>
|
||||
|
||||
<!-- Avatar Section (overlapping header) -->
|
||||
<div class="flex justify-between items-start px-4 -mt-16 relative z-10">
|
||||
<div class="flex-shrink-0">
|
||||
<%= if @profile_user.avatar_url do %>
|
||||
<img
|
||||
src={@profile_user.avatar_url}
|
||||
alt={@profile_user.display_name || @profile_user.username}
|
||||
class="w-32 h-32 rounded-full border-4 border-white dark:border-gray-900 object-cover"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="w-32 h-32 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold border-4 border-white dark:border-gray-900">
|
||||
<%= String.first(@profile_user.display_name || @profile_user.username) |> String.upcase() %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<%= if @current_user.id == @profile_user.id do %>
|
||||
<.link
|
||||
navigate={~p"/settings/profile"}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Edit Profile
|
||||
</.link>
|
||||
<% else %>
|
||||
<%= if following?(@current_user, @profile_user) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 mt-4">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<%= if @profile_user.bio do %>
|
||||
<p class="whitespace-pre-wrap"><%= @profile_user.bio %></p>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-gray-500">
|
||||
<%= if @profile_user.location do %>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @profile_user.location %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @profile_user.website do %>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<a href={@profile_user.website} target="_blank" class="text-blue-500 hover:underline">
|
||||
<%= URI.parse(@profile_user.website).host || @profile_user.website %>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Joined <%= Timex.format!(@profile_user.inserted_at, "{Mshort} {YYYY}") %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<div>
|
||||
<span class="font-bold"><%= @profile_user.following_count %></span>
|
||||
<span class="text-gray-500"> Following</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold"><%= @profile_user.followers_count %></span>
|
||||
<span class="text-gray-500"> Followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :posts && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Posts
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :replies && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Replies
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :media && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Media
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :likes && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Likes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post List -->
|
||||
<div id="posts" phx-update="stream" class="space-y-4">
|
||||
<.post_card
|
||||
:for={{dom_id, post} <- @streams.posts}
|
||||
dom_id={dom_id}
|
||||
post={post}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp load_posts(socket) do
|
||||
posts = Social.list_user_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_replies(socket) do
|
||||
# For now, just show all posts including replies
|
||||
posts = Social.list_user_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_media(socket) do
|
||||
posts = Social.list_user_media_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_likes(socket) do
|
||||
posts = Social.list_user_liked_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp reload_current_tab(socket) do
|
||||
case socket.assigns.active_tab do
|
||||
:posts -> load_posts(socket)
|
||||
:replies -> load_replies(socket)
|
||||
:media -> load_media(socket)
|
||||
:likes -> load_likes(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp following?(current_user, profile_user) do
|
||||
Social.following?(current_user.id, profile_user.id)
|
||||
end
|
||||
end
|
||||
333
lib/malarkey_web/live/profile_settings_live.ex
Normal file
333
lib/malarkey_web/live/profile_settings_live.ex
Normal file
@@ -0,0 +1,333 @@
|
||||
defmodule MalarkeyWeb.ProfileSettingsLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
alias Malarkey.{Accounts, Media}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_user
|
||||
changeset = Accounts.User.profile_changeset(user, %{})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Edit Profile")
|
||||
|> assign(:changeset, changeset)
|
||||
|> assign(:avatar_preview, user.avatar_url)
|
||||
|> assign(:header_preview, user.header_url)
|
||||
|> allow_upload(:avatar,
|
||||
accept: ~w(.jpg .jpeg .png .gif),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000
|
||||
)
|
||||
|> allow_upload(:header,
|
||||
accept: ~w(.jpg .jpeg .png .gif),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.User.profile_changeset(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_avatar", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :avatar, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_header", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :header, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_avatar", _params, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, %{avatar_url: nil}) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:avatar_preview, nil)
|
||||
|> put_flash(:info, "Avatar removed successfully")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove avatar")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_header", _params, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, %{header_url: nil}) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:header_preview, nil)
|
||||
|> put_flash(:info, "Header image removed successfully")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove header image")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
# Handle avatar upload
|
||||
avatar_url =
|
||||
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
|
||||
case Media.upload_file(path, entry.client_type, socket.assigns.current_user.id) do
|
||||
{:ok, url} -> {:ok, url}
|
||||
{:error, _} -> {:postpone, :error}
|
||||
end
|
||||
end)
|
||||
|> List.first()
|
||||
|
||||
# Handle header upload
|
||||
header_url =
|
||||
consume_uploaded_entries(socket, :header, fn %{path: path}, entry ->
|
||||
case Media.upload_file(path, entry.client_type, socket.assigns.current_user.id) do
|
||||
{:ok, url} -> {:ok, url}
|
||||
{:error, _} -> {:postpone, :error}
|
||||
end
|
||||
end)
|
||||
|> List.first()
|
||||
|
||||
# Merge uploaded URLs with form params
|
||||
user_params =
|
||||
user_params
|
||||
|> maybe_put_avatar(avatar_url)
|
||||
|> maybe_put_header(header_url)
|
||||
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, user_params) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:avatar_preview, user.avatar_url)
|
||||
|> assign(:header_preview, user.header_url)
|
||||
|> put_flash(:info, "Profile updated successfully")
|
||||
|> push_navigate(to: ~p"/#{user.username}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_avatar(params, nil), do: params
|
||||
defp maybe_put_avatar(params, url), do: Map.put(params, "avatar_url", url)
|
||||
|
||||
defp maybe_put_header(params, nil), do: params
|
||||
defp maybe_put_header(params, url), do: Map.put(params, "header_url", url)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<.link navigate={~p"/#{@current_user.username}"} class="text-blue-500 hover:underline">
|
||||
← Back to profile
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-2xl font-bold">Edit Profile</h1>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="profile-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="space-y-8"
|
||||
>
|
||||
<!-- Avatar Image -->
|
||||
<div class="pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block mb-4 text-sm font-medium">Profile Picture</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<%= if @avatar_preview do %>
|
||||
<img
|
||||
src={@avatar_preview}
|
||||
alt="Avatar"
|
||||
class="object-cover w-32 h-32 border-4 border-white rounded-full dark:border-gray-800"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center w-32 h-32 text-4xl font-bold text-gray-600 bg-gray-300 border-4 border-white rounded-full dark:bg-gray-600 dark:text-gray-300 dark:border-gray-800">
|
||||
<%= String.first(@current_user.username) |> String.upcase() %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label
|
||||
for={@uploads.avatar.ref}
|
||||
class="px-4 py-2 text-sm font-semibold text-white transition-colors bg-blue-500 rounded-full cursor-pointer hover:bg-blue-600"
|
||||
>
|
||||
<svg class="inline w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Upload Photo
|
||||
</label>
|
||||
<%= if @avatar_preview do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_avatar"
|
||||
class="px-4 py-2 text-sm font-semibold text-white transition-colors bg-red-500 rounded-full hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.live_file_input upload={@uploads.avatar} class="hidden" />
|
||||
|
||||
<%= for entry <- @uploads.avatar.entries do %>
|
||||
<div class="flex items-center justify-between p-2 mt-2 bg-gray-100 rounded dark:bg-gray-700">
|
||||
<span class="text-sm"><%= entry.client_name %></span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_avatar"
|
||||
phx-value-ref={entry.ref}
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Header Image -->
|
||||
<div class="pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block mb-4 text-sm font-medium">Header Image</label>
|
||||
<div class="relative">
|
||||
<div class="h-48 overflow-hidden bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg">
|
||||
<%= if @header_preview do %>
|
||||
<img src={@header_preview} alt="Header" class="object-cover w-full h-full" />
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="absolute flex gap-2 top-2 right-2">
|
||||
<label
|
||||
for={@uploads.header.ref}
|
||||
class="px-3 py-2 text-sm font-semibold text-white transition-colors bg-black rounded-full cursor-pointer bg-opacity-60 hover:bg-opacity-80"
|
||||
>
|
||||
<svg class="inline w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Change
|
||||
</label>
|
||||
<%= if @header_preview do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_header"
|
||||
class="px-3 py-2 text-sm font-semibold text-white transition-colors bg-red-600 rounded-full bg-opacity-60 hover:bg-opacity-80"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.live_file_input upload={@uploads.header} class="hidden" />
|
||||
|
||||
<%= for entry <- @uploads.header.entries do %>
|
||||
<div class="flex items-center justify-between p-2 mt-2 bg-gray-100 rounded dark:bg-gray-700">
|
||||
<span class="text-sm"><%= entry.client_name %></span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_header"
|
||||
phx-value-ref={entry.ref}
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:display_name]}
|
||||
type="text"
|
||||
label="Display Name"
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:bio]}
|
||||
type="textarea"
|
||||
label="Bio"
|
||||
placeholder="Tell us about yourself"
|
||||
rows="3"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= String.length(Phoenix.HTML.Form.input_value(f, :bio) || "") %>/160
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<.input field={f[:location]} type="text" label="Location" placeholder="Where are you?" />
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:website]}
|
||||
type="text"
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-6 py-2 font-semibold text-white transition-colors bg-blue-500 rounded-full hover:bg-blue-600"
|
||||
phx-disable-with="Saving..."
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<.link
|
||||
navigate={~p"/#{@current_user.username}"}
|
||||
class="flex items-center justify-center flex-1 px-6 py-2 font-semibold text-gray-700 transition-colors border border-gray-300 rounded-full hover:bg-gray-100 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
600
lib/malarkey_web/live/timeline_live/index.ex
Normal file
600
lib/malarkey_web/live/timeline_live/index.ex
Normal file
@@ -0,0 +1,600 @@
|
||||
defmodule MalarkeyWeb.TimelineLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Posts
|
||||
import MalarkeyWeb.Components.PostComposer
|
||||
|
||||
alias Malarkey.{Social, Media, Giphy}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Malarkey.PubSub, "posts:new")
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Home")
|
||||
|> assign(:composer_open, false)
|
||||
|> assign(:post_body, "")
|
||||
|> assign(:char_count, 0)
|
||||
|> assign(:uploaded_files, [])
|
||||
|> assign(:giphy_modal_open, false)
|
||||
|> assign(:giphy_search, "")
|
||||
|> assign(:giphy_results, [])
|
||||
|> assign(:giphy_loading, false)
|
||||
|> assign(:emoji_picker_open, false)
|
||||
|> assign(:emoji_category, "smileys")
|
||||
|> assign(:emoji_search, "")
|
||||
|> assign(:filtered_emojis, get_emoji_category("smileys"))
|
||||
|> assign(:reply_modal_open, false)
|
||||
|> assign(:reply_to_post, nil)
|
||||
|> assign(:reply_content, "")
|
||||
|> assign(:reply_char_count, 0)
|
||||
|> allow_upload(:media, accept: ~w(.jpg .jpeg .png .gif .mp4 .mov .webm), max_entries: 4, max_file_size: 50_000_000)
|
||||
|> stream(:posts, Social.list_timeline_posts(socket.assigns.current_user, limit: 50))}
|
||||
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, "Home")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_composer", _, socket) do
|
||||
{:noreply, assign(socket, composer_open: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_composer", _, socket) do
|
||||
{:noreply, assign(socket, composer_open: false, post_body: "", char_count: 0, uploaded_files: [], giphy_modal_open: false, emoji_picker_open: false, emoji_search: "", emoji_category: "smileys", filtered_emojis: get_emoji_category("smileys"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_post", %{"value" => content}, socket) do
|
||||
char_count = String.length(content)
|
||||
{:noreply, assign(socket, post_body: content, char_count: char_count)}
|
||||
end
|
||||
|
||||
def handle_event("update_post", _params, socket) do
|
||||
# Fallback in case event structure is different
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_upload", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :media, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_giphy", _, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:giphy_modal_open, true)
|
||||
|> assign(:giphy_loading, true)
|
||||
|
||||
send(self(), :load_trending_gifs)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_giphy", _, socket) do
|
||||
{:noreply, assign(socket, giphy_modal_open: false, giphy_search: "", giphy_results: [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search_giphy", %{"search" => query}, socket) do
|
||||
socket = assign(socket, giphy_search: query, giphy_loading: true)
|
||||
send(self(), {:search_giphy, query})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_gif", %{"url" => url}, socket) do
|
||||
uploaded_files = socket.assigns.uploaded_files ++ [{url, "gif"}]
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:uploaded_files, uploaded_files)
|
||||
|> assign(:giphy_modal_open, false)
|
||||
|> assign(:giphy_search, "")
|
||||
|> assign(:giphy_results, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_media", %{"index" => index}, socket) do
|
||||
index = String.to_integer(index)
|
||||
uploaded_files = List.delete_at(socket.assigns.uploaded_files, index)
|
||||
{:noreply, assign(socket, :uploaded_files, uploaded_files)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_emoji_picker", _, socket) do
|
||||
is_open = !Map.get(socket.assigns, :emoji_picker_open, false)
|
||||
{:noreply, assign(socket, emoji_picker_open: is_open, emoji_search: "", emoji_category: "smileys", filtered_emojis: get_emoji_category("smileys"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("insert_emoji", %{"emoji" => emoji}, socket) do
|
||||
updated_body = socket.assigns.post_body <> emoji
|
||||
char_count = String.length(updated_body)
|
||||
{:noreply, assign(socket, post_body: updated_body, char_count: char_count)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_emoji_category", %{"category" => category}, socket) do
|
||||
emojis = if socket.assigns.emoji_search != "", do: search_emojis(socket.assigns.emoji_search), else: get_emoji_category(category)
|
||||
{:noreply, assign(socket, emoji_category: category, filtered_emojis: emojis)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search_emoji", %{"search" => search}, socket) do
|
||||
search = String.downcase(search)
|
||||
emojis = if search == "", do: get_emoji_category(socket.assigns.emoji_category), else: search_emojis(search)
|
||||
{:noreply, assign(socket, emoji_search: search, filtered_emojis: emojis)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("paste_image", %{"data" => data, "type" => type}, socket) do
|
||||
require Logger
|
||||
Logger.info("Received paste_image event with type: #{type}")
|
||||
|
||||
try do
|
||||
# Extract base64 data
|
||||
[_prefix, base64] = String.split(data, ",", parts: 2)
|
||||
binary = Base.decode64!(base64)
|
||||
Logger.info("Decoded image binary, size: #{byte_size(binary)} bytes")
|
||||
|
||||
case Media.upload_binary(binary, type, socket.assigns.current_user.id) do
|
||||
{:ok, url} ->
|
||||
Logger.info("Successfully uploaded to S3: #{url}")
|
||||
media_type = Media.get_media_type(type)
|
||||
uploaded_files = socket.assigns.uploaded_files ++ [{url, media_type}]
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:uploaded_files, uploaded_files)
|
||||
|> put_flash(:info, "Image pasted successfully!")}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to upload pasted image: #{inspect(reason)}")
|
||||
{:noreply, put_flash(socket, :error, "Failed to upload pasted image. Please check AWS S3 configuration.")}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Error processing pasted image: #{inspect(e)}")
|
||||
{:noreply, put_flash(socket, :error, "Error processing pasted image: #{Exception.message(e)}")}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@impl true
|
||||
def handle_event("post_post", params, socket) do
|
||||
# Accept both "body" and "content" for legacy/new posts
|
||||
body = Map.get(params, "body") || Map.get(params, "content") || ""
|
||||
|
||||
# Upload any pending files
|
||||
media_urls =
|
||||
consume_uploaded_entries(socket, :media, fn %{path: path}, entry ->
|
||||
content_type = entry.client_type
|
||||
user_id = socket.assigns.current_user.id
|
||||
|
||||
case Media.upload_file(path, content_type, user_id) do
|
||||
{:ok, url} -> {:ok, url}
|
||||
{:error, _} -> {:postpone, :error}
|
||||
end
|
||||
end)
|
||||
|
||||
# Add any GIFs or pasted images
|
||||
all_media = media_urls ++ Enum.map(socket.assigns.uploaded_files, fn {url, _type} -> url end)
|
||||
media_types = Enum.map(socket.assigns.uploaded_files, fn {_url, type} -> type end)
|
||||
|
||||
safe_body =
|
||||
case body do
|
||||
nil -> ""
|
||||
b when is_binary(b) -> String.trim(b)
|
||||
_ -> ""
|
||||
end
|
||||
|
||||
# If media is present and body is empty, set body to a single space to satisfy NOT NULL constraint
|
||||
safe_body =
|
||||
if safe_body == "" and all_media != [] do
|
||||
" "
|
||||
else
|
||||
safe_body
|
||||
end
|
||||
|
||||
post_attrs = %{
|
||||
body: safe_body,
|
||||
user_id: socket.assigns.current_user.id,
|
||||
media_urls: all_media,
|
||||
media_types: media_types
|
||||
}
|
||||
|
||||
case Social.create_post(post_attrs) do
|
||||
{:ok, post} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Post posted successfully")
|
||||
|> assign(composer_open: false, post_body: "", char_count: 0, uploaded_files: [])
|
||||
|> stream_insert(:posts, post, at: 0)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
errors = changeset_errors(changeset)
|
||||
{:noreply, put_flash(socket, :error, "Failed to post post: #{errors}")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("like_post", %{"id" => id}, socket) do
|
||||
case Social.create_like(%{user_id: socket.assigns.current_user.id, post_id: id}) do
|
||||
{:ok, _like} ->
|
||||
updated_post = Social.get_post!(id)
|
||||
{:noreply, stream_insert(socket, :posts, updated_post)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to like post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlike_post", %{"id" => id}, socket) do
|
||||
case Social.delete_like(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
updated_post = Social.get_post!(id)
|
||||
{:noreply, stream_insert(socket, :posts, updated_post)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlike post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("repost", %{"id" => id}, socket) do
|
||||
case Social.create_repost(socket.assigns.current_user.id, id) do
|
||||
{:ok, _repost} ->
|
||||
{:noreply, put_flash(socket, :info, "Reposted successfully")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to repost")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_repost", %{"id" => id}, socket) do
|
||||
case Social.delete_repost(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, put_flash(socket, :info, "Repost removed")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove repost")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("navigate", %{"url" => url}, socket) do
|
||||
{:noreply, push_navigate(socket, to: url)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_reply_modal", %{"id" => id}, socket) do
|
||||
post = Social.get_post!(id)
|
||||
{:noreply, assign(socket, reply_modal_open: true, reply_to_post: post, reply_content: "", reply_char_count: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_reply_modal", _, socket) do
|
||||
{:noreply, assign(socket, reply_modal_open: false, reply_to_post: nil, reply_content: "", reply_char_count: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("stop_propagation", _, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_reply", %{"value" => content}, socket) do
|
||||
char_count = String.length(content)
|
||||
{:noreply, assign(socket, reply_content: content, reply_char_count: char_count)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("post_reply", _, socket) do
|
||||
attrs = %{
|
||||
body: socket.assigns.reply_content,
|
||||
user_id: socket.assigns.current_user.id,
|
||||
reply_to_id: socket.assigns.reply_to_post.id
|
||||
}
|
||||
|
||||
case Social.create_post(attrs) do
|
||||
{:ok, _reply} ->
|
||||
# Update the parent post in the stream to reflect new reply count
|
||||
updated_post = Social.get_post!(socket.assigns.reply_to_post.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Reply posted successfully")
|
||||
|> assign(reply_modal_open: false, reply_to_post: nil, reply_content: "", reply_char_count: 0)
|
||||
|> stream_insert(:posts, updated_post)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
errors = changeset_errors(changeset)
|
||||
{:noreply, put_flash(socket, :error, "Failed to post reply: #{errors}")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_post", %{"id" => id}, socket) do
|
||||
post = Social.get_post!(id)
|
||||
|
||||
# Only allow the post owner to delete
|
||||
if post.user_id == socket.assigns.current_user.id do
|
||||
case Social.delete_post(post) do
|
||||
{:ok, _} ->
|
||||
# Send event to trigger animation, then wait before removing from stream
|
||||
send(self(), {:remove_post_from_stream, post})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Post deleted successfully")
|
||||
|> push_event("delete-post", %{id: "posts-#{post.id}"})}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to delete post")}
|
||||
end
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "You can only delete your own posts")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:remove_post_from_stream, post}, socket) do
|
||||
# Delay to allow animation to complete
|
||||
Process.sleep(300)
|
||||
{:noreply, stream_delete(socket, :posts, post)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:load_trending_gifs, socket) do
|
||||
case Giphy.trending(25) do
|
||||
{:ok, gifs} ->
|
||||
{:noreply, assign(socket, giphy_results: gifs, giphy_loading: false)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(giphy_loading: false)
|
||||
|> put_flash(:error, "Failed to load GIFs")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:search_giphy, query}, socket) do
|
||||
case Giphy.search(query, 25) do
|
||||
{:ok, gifs} ->
|
||||
{:noreply, assign(socket, giphy_results: gifs, giphy_loading: false)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(giphy_loading: false)
|
||||
|> put_flash(:error, "Failed to search GIFs")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:new_post, post}, socket) do
|
||||
{:noreply, stream_insert(socket, :posts, post, at: 0)}
|
||||
end
|
||||
|
||||
def handle_info(_msg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Emoji helper functions
|
||||
defp get_all_emojis do
|
||||
%{
|
||||
"smileys" => ["😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤", "😴"],
|
||||
"emotions" => ["😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬"],
|
||||
"people" => ["👶", "👧", "🧒", "👦", "👩", "🧑", "👨", "👩🦱", "🧑🦱", "👨🦱", "👩🦰", "🧑🦰", "👨🦰", "👱♀️", "👱", "👱♂️", "👩🦳", "🧑🦳", "👨🦳", "👩🦲", "🧑🦲", "👨🦲", "🧔", "👵", "🧓", "👴", "👲", "👳♀️", "👳", "👳♂️", "🧕", "👮♀️", "👮", "👮♂️", "👷♀️", "👷", "👷♂️", "💂♀️", "💂", "💂♂️"],
|
||||
"gestures" => ["👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", "👇", "☝️", "✋", "🤚", "🖐️", "🖖", "👋", "🤙", "💪", "🦾", "🖕", "✍️", "🙏", "🦶", "🦵", "🦿", "👄", "🦷", "👅", "👂", "🦻", "👃", "👣", "👁️", "👀", "🧠", "🫀", "🫁", "🦴"],
|
||||
"animals" => ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷️", "🕸️"],
|
||||
"nature" => ["💐", "🌸", "💮", "🏵️", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🌱", "🌲", "🌳", "🌴", "🌵", "🌾", "🌿", "☘️", "🍀", "🍁", "🍂", "🍃", "🍄", "🌰", "🦀", "🦞", "🦐", "🦑", "🐙", "🦪", "🐚", "🪨", "🌍", "🌎", "🌏", "🌐", "🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
|
||||
"food" => ["🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶️", "🌽", "🥕", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖"],
|
||||
"drinks" => ["☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣", "🥡", "🥢", "🧂"],
|
||||
"sports" => ["⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷", "⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "🤺", "⛹️", "🤾", "🏌️", "🏇"],
|
||||
"travel" => ["🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🦯", "🦽", "🦼", "🛴", "🚲", "🛵", "🏍️", "🛺", "🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋", "🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉", "✈️", "🛫"],
|
||||
"objects" => ["⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️", "🗜️", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥", "📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️", "🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋", "🔌", "💡", "🔦", "🕯️", "🪔", "🧯"],
|
||||
"symbols" => ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉️", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔"],
|
||||
"flags" => ["🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️🌈", "🏳️⚧️", "🏴☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼"]
|
||||
}
|
||||
end
|
||||
|
||||
defp get_emoji_category(category) do
|
||||
get_all_emojis()[category] || []
|
||||
end
|
||||
|
||||
defp search_emojis(search_term) do
|
||||
search_term = String.downcase(search_term)
|
||||
|
||||
all_emojis = get_all_emojis()
|
||||
|
||||
# Define searchable keywords for each category
|
||||
keywords = %{
|
||||
"smileys" => ["smile", "happy", "laugh", "grin", "joy", "face"],
|
||||
"emotions" => ["sad", "cry", "angry", "sick", "tired", "emotion", "feel"],
|
||||
"people" => ["person", "people", "man", "woman", "child", "baby", "old"],
|
||||
"gestures" => ["hand", "finger", "thumb", "point", "wave", "clap", "pray"],
|
||||
"animals" => ["animal", "dog", "cat", "bird", "fish", "pet", "wild"],
|
||||
"nature" => ["flower", "plant", "tree", "leaf", "nature", "earth", "moon"],
|
||||
"food" => ["food", "fruit", "vegetable", "meal", "eat"],
|
||||
"drinks" => ["drink", "coffee", "tea", "beer", "wine", "cup"],
|
||||
"sports" => ["sport", "ball", "game", "play", "exercise"],
|
||||
"travel" => ["car", "travel", "vehicle", "transport", "train", "plane"],
|
||||
"objects" => ["object", "phone", "computer", "clock", "camera", "tool"],
|
||||
"symbols" => ["heart", "love", "symbol", "sign", "star"],
|
||||
"flags" => ["flag", "country", "nation"]
|
||||
}
|
||||
|
||||
# Find matching categories
|
||||
matching_categories =
|
||||
Enum.filter(keywords, fn {_category, words} ->
|
||||
Enum.any?(words, fn word -> String.contains?(word, search_term) end)
|
||||
end)
|
||||
|> Enum.map(fn {category, _} -> category end)
|
||||
|
||||
# Get emojis from matching categories
|
||||
matching_categories
|
||||
|> Enum.flat_map(fn category -> all_emojis[category] || [] end)
|
||||
|> Enum.take(64) # Limit results
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="container max-w-2xl py-6 mx-auto space-y-4">
|
||||
<.ui_card>
|
||||
<.ui_card_header class="border-b">
|
||||
<.ui_card_title class="text-2xl">Home</.ui_card_title>
|
||||
</.ui_card_header>
|
||||
|
||||
<.ui_card_content class="pt-6">
|
||||
<.post_composer
|
||||
current_user={@current_user}
|
||||
body={@post_body}
|
||||
char_count={@char_count}
|
||||
uploaded_files={@uploaded_files}
|
||||
uploads={@uploads}
|
||||
submit_event="post_post"
|
||||
update_event="update_post"
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div id="posts" phx-update="stream" class="space-y-4">
|
||||
<.post_card
|
||||
:for={{dom_id, post} <- @streams.posts}
|
||||
dom_id={dom_id}
|
||||
post={post}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Giphy Modal -->
|
||||
<div
|
||||
:if={@giphy_modal_open}
|
||||
class="fixed inset-0 z-50 flex items-start justify-center p-4 bg-black/50"
|
||||
phx-click="close_giphy"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-4xl mt-20 overflow-hidden rounded-lg shadow-lg bg-background"
|
||||
phx-click="stop_propagation"
|
||||
>
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Choose a GIF</h2>
|
||||
<button
|
||||
phx-click="close_giphy"
|
||||
class="p-2 transition-colors rounded-full hover:bg-muted"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<input
|
||||
type="text"
|
||||
value={@giphy_search}
|
||||
phx-keyup="search_giphy"
|
||||
phx-debounce="300"
|
||||
placeholder="Search for GIFs..."
|
||||
class="w-full px-4 py-2 mb-4 transition-colors border rounded-md bg-background border-border focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 overflow-y-auto max-h-96">
|
||||
<%= for gif <- @giphy_results do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="select_gif"
|
||||
phx-value-url={gif["images"]["fixed_height"]["url"]}
|
||||
class="relative overflow-hidden transition-transform rounded-lg aspect-square hover:scale-105"
|
||||
>
|
||||
<img
|
||||
src={gif["images"]["fixed_height"]["url"]}
|
||||
alt={gif["title"]}
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Modal -->
|
||||
<div
|
||||
:if={@reply_modal_open}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
phx-click="close_reply_modal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-xl overflow-hidden rounded-lg shadow-lg bg-background"
|
||||
phx-click="stop_propagation"
|
||||
>
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Reply to @<%= @reply_to_post && @reply_to_post.user.username %></h2>
|
||||
<button
|
||||
phx-click="close_reply_modal"
|
||||
class="p-2 transition-colors rounded-full hover:bg-muted"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<.post_composer
|
||||
current_user={@current_user}
|
||||
body={@reply_content}
|
||||
char_count={@reply_char_count}
|
||||
uploaded_files={[]}
|
||||
uploads={@uploads}
|
||||
submit_event="post_reply"
|
||||
update_event="update_reply"
|
||||
placeholder="Post your reply"
|
||||
reply_to={@reply_to_post}
|
||||
show_cancel={true}
|
||||
cancel_event="close_reply_modal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp changeset_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
||||
|> Enum.map(fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end)
|
||||
|> Enum.join("; ")
|
||||
end
|
||||
end
|
||||
@@ -1,45 +0,0 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationInstructionsLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Resend confirmation instructions</.header>
|
||||
|
||||
<.simple_form :let={f} for={:user} id="resend_confirmation_form" phx-submit="send_instructions">
|
||||
<.input field={{f, :email}} type="email" label="Email" required />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Sending...">Resend confirmation instructions</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p>
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
|
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&url(~p"/users/confirm/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
||||
@@ -1,56 +0,0 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def render(%{live_action: :edit} = assigns) do
|
||||
~H"""
|
||||
<.header>Confirm Account</.header>
|
||||
|
||||
<.simple_form :let={f} for={:user} id="confirmation_form" phx-submit="confirm_account">
|
||||
<.input field={{f, :token}} type="hidden" value={@token} />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Confirming...">Confirm my account</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p>
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
|
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, assign(socket, token: params["token"]), temporary_assigns: [token: nil]}
|
||||
end
|
||||
|
||||
# Do not log in the user after confirmation to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "User confirmed successfully.")
|
||||
|> redirect(to: ~p"/")}
|
||||
|
||||
:error ->
|
||||
# If there is a current user and the account was already confirmed,
|
||||
# then odds are that the confirmation link was already visited, either
|
||||
# by some automation or by the user themselves, so we redirect without
|
||||
# a warning message.
|
||||
case socket.assigns do
|
||||
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
|
||||
{:noreply, redirect(socket, to: ~p"/")}
|
||||
|
||||
%{} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,30 +1,43 @@
|
||||
defmodule MalarkeyWeb.UserForgotPasswordLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Forgot your password?
|
||||
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
|
||||
</.header>
|
||||
<div class="flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<.ui_card class="w-full max-w-md">
|
||||
<.ui_card_header>
|
||||
<.ui_card_title class="text-2xl text-center">Forgot password?</.ui_card_title>
|
||||
<.ui_card_description class="text-center">
|
||||
We'll send a password reset link to your inbox
|
||||
</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
|
||||
<.simple_form :let={f} id="reset_password_form" for={:user} phx-submit="send_email">
|
||||
<.input field={{f, :email}} type="email" placeholder="Email" required />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Sending..." class="w-full">
|
||||
Send password reset instructions
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<.ui_card_content>
|
||||
<.form for={@form} id="reset_password_form" phx-submit="send_email" class="space-y-4">
|
||||
<.ui_input field={@form[:email]} type="email" label="Email" placeholder="your@email.com" required />
|
||||
|
||||
<.ui_button type="submit" class="w-full" phx-disable-with="Sending...">
|
||||
Send reset instructions
|
||||
</.ui_button>
|
||||
</.form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||
<.link href={~p"/users/register"} class="font-medium text-primary hover:underline">Register</.link>
|
||||
{" · "}
|
||||
<.link href={~p"/users/log_in"} class="font-medium text-primary hover:underline">Log in</.link>
|
||||
</p>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
|
||||
end
|
||||
|
||||
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
|
||||
|
||||
@@ -1,49 +1,74 @@
|
||||
defmodule MalarkeyWeb.UserLoginLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Sign in to account
|
||||
<:subtitle>
|
||||
Don't have an account?
|
||||
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||
Sign up
|
||||
</.link>
|
||||
for an account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<div class="flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<.ui_card class="w-full max-w-md">
|
||||
<.ui_card_header>
|
||||
<.ui_card_title class="text-2xl text-center">Welcome back</.ui_card_title>
|
||||
<.ui_card_description class="text-center">
|
||||
Sign in to your account to continue
|
||||
</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
id="login_form"
|
||||
for={:user}
|
||||
action={~p"/users/log_in"}
|
||||
as={:user}
|
||||
phx-update="ignore"
|
||||
>
|
||||
<.input field={{f, :email}} type="email" label="Email" required />
|
||||
<.input field={{f, :password}} type="password" label="Password" required />
|
||||
<.ui_card_content>
|
||||
<.form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore" class="space-y-4">
|
||||
<.ui_input field={@form[:email]} type="email" label="Email" required />
|
||||
<.ui_input field={@form[:password]} type="password" label="Password" required />
|
||||
|
||||
<:actions :let={f}>
|
||||
<.input field={{f, :remember_me}} type="checkbox" label="Keep me logged in" />
|
||||
<.link href={~p"/users/reset_password"} class="text-sm font-semibold">
|
||||
Forgot your password?
|
||||
</.link>
|
||||
</:actions>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Sigining in..." class="w-full">
|
||||
Sign in <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<div class="flex items-center justify-between">
|
||||
<.ui_input field={@form[:remember_me]} type="checkbox" label="Remember me" />
|
||||
<.link href={~p"/users/reset_password"} class="text-sm font-medium text-primary hover:underline">
|
||||
Forgot password?
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.ui_button type="submit" class="w-full" phx-disable-with="Signing in...">
|
||||
Sign in
|
||||
</.ui_button>
|
||||
</.form>
|
||||
|
||||
<div class="mt-6">
|
||||
<.ui_separator class="my-4" />
|
||||
<p class="text-center text-sm text-muted-foreground mb-4">Or continue with</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<.link href={~p"/auth/google"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
Google
|
||||
</.ui_button>
|
||||
</.link>
|
||||
<.link href={~p"/auth/github"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
GitHub
|
||||
</.ui_button>
|
||||
</.link>
|
||||
<.link href={~p"/auth/twitter"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
Twitter
|
||||
</.ui_button>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?
|
||||
<.link navigate={~p"/users/register"} class="font-medium text-primary hover:underline">
|
||||
Sign up
|
||||
</.link>
|
||||
</p>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
email = live_flash(socket.assigns.flash, :email)
|
||||
{:ok, assign(socket, email: email), temporary_assigns: [email: nil]}
|
||||
email = Phoenix.Flash.get(socket.assigns.flash, :email)
|
||||
form = to_form(%{"email" => email}, as: "user")
|
||||
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,53 +1,90 @@
|
||||
defmodule MalarkeyWeb.UserRegistrationLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Accounts.User
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-sm mx-auto">
|
||||
<.header class="text-center">
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
|
||||
Sign in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<div class="flex items-center justify-center min-h-screen px-4 py-12">
|
||||
<.ui_card class="w-full max-w-md">
|
||||
<.ui_card_header>
|
||||
<.ui_card_title class="text-2xl text-center">Create an account</.ui_card_title>
|
||||
<.ui_card_description class="text-center">
|
||||
Enter your details below to create your account
|
||||
</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
id="registration_form"
|
||||
for={@changeset}
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
action={~p"/users/log_in?_action=registered"}
|
||||
method="post"
|
||||
as={:user}
|
||||
>
|
||||
<.error :if={@changeset.action == :insert}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
<.ui_card_content>
|
||||
<.form
|
||||
for={@form}
|
||||
id="registration_form"
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
action={~p"/users/log_in?_action=registered"}
|
||||
method="post"
|
||||
class="space-y-4"
|
||||
>
|
||||
<.ui_alert :if={@check_errors} variant="destructive">
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.ui_alert>
|
||||
|
||||
<.input field={{f, :email}} type="email" label="Email" required />
|
||||
<.input field={{f, :password}} type="password" label="Password" required />
|
||||
<.ui_input field={@form[:username]} type="text" label="Username" required />
|
||||
<.ui_input field={@form[:email]} type="email" label="Email" required />
|
||||
<.ui_input field={@form[:password]} type="password" label="Password" required />
|
||||
|
||||
<:actions>
|
||||
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<.ui_button type="submit" class="w-full" phx-disable-with="Creating account...">
|
||||
Create account
|
||||
</.ui_button>
|
||||
</.form>
|
||||
|
||||
<div class="mt-6">
|
||||
<.ui_separator class="my-4" />
|
||||
<p class="mb-4 text-sm text-center text-muted-foreground">Or continue with</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<.link href={~p"/auth/google"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
Google
|
||||
</.ui_button>
|
||||
</.link>
|
||||
<.link href={~p"/auth/github"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
GitHub
|
||||
</.ui_button>
|
||||
</.link>
|
||||
<.link href={~p"/auth/twitter"}>
|
||||
<.ui_button variant="outline" class="w-full">
|
||||
Twitter
|
||||
</.ui_button>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-center text-muted-foreground">
|
||||
Already have an account?
|
||||
<.link navigate={~p"/users/log_in"} class="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</.link>
|
||||
</p>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
socket = assign(socket, changeset: changeset, trigger_submit: false)
|
||||
{:ok, socket, temporary_assigns: [changeset: nil]}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(trigger_submit: false, check_errors: false)
|
||||
|> assign_form(changeset)
|
||||
|
||||
{:ok, socket, temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
@@ -60,15 +97,25 @@ defmodule MalarkeyWeb.UserRegistrationLive do
|
||||
)
|
||||
|
||||
changeset = Accounts.change_user_registration(user)
|
||||
{:noreply, assign(socket, trigger_submit: true, changeset: changeset)}
|
||||
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_registration(%User{}, user_params)
|
||||
{:noreply, assign(socket, changeset: Map.put(changeset, :action, :validate))}
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "user")
|
||||
|
||||
if changeset.valid? do
|
||||
assign(socket, form: form, check_errors: false)
|
||||
else
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,56 +1,70 @@
|
||||
defmodule MalarkeyWeb.UserResetPasswordLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Reset Password</.header>
|
||||
<div class="flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<.ui_card class="w-full max-w-md">
|
||||
<.ui_card_header>
|
||||
<.ui_card_title class="text-2xl text-center">Reset password</.ui_card_title>
|
||||
<.ui_card_description class="text-center">
|
||||
Enter your new password below
|
||||
</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="reset_password_form"
|
||||
phx-submit="reset_password"
|
||||
phx-change="validate"
|
||||
>
|
||||
<.error :if={@changeset.action == :insert}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
<.ui_card_content>
|
||||
<.form
|
||||
for={@form}
|
||||
id="reset_password_form"
|
||||
phx-submit="reset_password"
|
||||
phx-change="validate"
|
||||
class="space-y-4"
|
||||
>
|
||||
<.ui_alert :if={@form.errors != []} variant="destructive">
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.ui_alert>
|
||||
|
||||
<.input field={{f, :password}} type="password" label="New password" required />
|
||||
<.input
|
||||
field={{f, :password_confirmation}}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Resetting...">Reset Password</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<.ui_input field={@form[:password]} type="password" label="New password" required />
|
||||
<.ui_input
|
||||
field={@form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
required
|
||||
/>
|
||||
|
||||
<p>
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
|
|
||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
||||
</p>
|
||||
<.ui_button type="submit" class="w-full" phx-disable-with="Resetting...">
|
||||
Reset password
|
||||
</.ui_button>
|
||||
</.form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||
<.link href={~p"/users/register"} class="font-medium text-primary hover:underline">Register</.link>
|
||||
{" · "}
|
||||
<.link href={~p"/users/log_in"} class="font-medium text-primary hover:underline">Log in</.link>
|
||||
</p>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(params, _session, socket) do
|
||||
socket = assign_user_and_token(socket, params)
|
||||
|
||||
socket =
|
||||
form_source =
|
||||
case socket.assigns do
|
||||
%{user: user} ->
|
||||
assign(socket, :changeset, Accounts.change_user_password(user))
|
||||
Accounts.change_user_password(user)
|
||||
|
||||
_ ->
|
||||
socket
|
||||
%{}
|
||||
end
|
||||
|
||||
{:ok, socket, temporary_assigns: [changeset: nil]}
|
||||
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
# Do not log in the user after reset password to avoid a
|
||||
@@ -64,13 +78,13 @@ defmodule MalarkeyWeb.UserResetPasswordLive do
|
||||
|> redirect(to: ~p"/users/log_in")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :changeset, Map.put(changeset, :action, :insert))}
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
|
||||
{:noreply, assign(socket, changeset: Map.put(changeset, :action, :validate))}
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_user_and_token(socket, %{"token" => token}) do
|
||||
@@ -82,4 +96,8 @@ defmodule MalarkeyWeb.UserResetPasswordLive do
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %{} = source) do
|
||||
assign(socket, :form, to_form(source, as: "user"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,72 +1,182 @@
|
||||
defmodule MalarkeyWeb.UserSettingsLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Change Email</.header>
|
||||
<div class="container max-w-4xl py-8 px-4">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Account Settings</h1>
|
||||
<p class="text-muted-foreground mt-2">Manage your account profile and password settings</p>
|
||||
</div>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
id="email_form"
|
||||
for={@email_changeset}
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.error :if={@email_changeset.action == :insert}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
<div class="space-y-6">
|
||||
<.ui_card>
|
||||
<.ui_card_header>
|
||||
<.ui_card_title>Appearance</.ui_card_title>
|
||||
<.ui_card_description>Customize the appearance of the app</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
<.ui_card_content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium">Theme</label>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
Select your preferred color scheme
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
phx-hook="ThemeSelector"
|
||||
data-theme="light"
|
||||
id="theme-light"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-input hover:border-primary transition-colors bg-background"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Light</span>
|
||||
</button>
|
||||
|
||||
<.input field={{f, :email}} type="email" label="Email" required />
|
||||
<button
|
||||
phx-hook="ThemeSelector"
|
||||
data-theme="dark"
|
||||
id="theme-dark"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-input hover:border-primary transition-colors bg-background"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Dark</span>
|
||||
</button>
|
||||
|
||||
<.input
|
||||
field={{f, :current_password}}
|
||||
name="current_password"
|
||||
id="current_password_for_email"
|
||||
type="password"
|
||||
label="Current password"
|
||||
value={@email_form_current_password}
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Changing...">Change Email</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<button
|
||||
phx-hook="ThemeSelector"
|
||||
data-theme="system"
|
||||
id="theme-system"
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-input hover:border-primary transition-colors bg-background"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
|
||||
<.header>Change Password</.header>
|
||||
<.ui_card>
|
||||
<.ui_card_header>
|
||||
<.ui_card_title>Profile Information</.ui_card_title>
|
||||
<.ui_card_description>Update your public profile information</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
<.ui_card_content>
|
||||
<.form
|
||||
for={@profile_form}
|
||||
id="profile_form"
|
||||
phx-submit="update_profile"
|
||||
phx-change="validate_profile"
|
||||
class="space-y-4"
|
||||
>
|
||||
<.ui_input field={@profile_form[:display_name]} type="text" label="Display name" />
|
||||
<.ui_input field={@profile_form[:username]} type="text" label="Username" required />
|
||||
<.ui_input field={@profile_form[:bio]} type="textarea" label="Bio" />
|
||||
<.ui_input field={@profile_form[:location]} type="text" label="Location" />
|
||||
<.ui_input field={@profile_form[:website]} type="url" label="Website" />
|
||||
<.ui_button type="submit" phx-disable-with="Saving...">Save Profile</.ui_button>
|
||||
</.form>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
|
||||
<.simple_form
|
||||
:let={f}
|
||||
id="password_form"
|
||||
for={@password_changeset}
|
||||
action={~p"/users/log_in?_action=password_updated"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<.error :if={@password_changeset.action == :insert}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
<.ui_card>
|
||||
<.ui_card_header>
|
||||
<.ui_card_title>Email Address</.ui_card_title>
|
||||
<.ui_card_description>Change your email address</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
<.ui_card_content>
|
||||
<.form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
class="space-y-4"
|
||||
>
|
||||
<.ui_input field={@email_form[:email]} type="email" label="Email" required />
|
||||
<.ui_input
|
||||
field={@email_form[:current_password]}
|
||||
name="current_password"
|
||||
id="current_password_for_email"
|
||||
type="password"
|
||||
label="Current password"
|
||||
value={@email_form_current_password}
|
||||
required
|
||||
/>
|
||||
<.ui_button type="submit" phx-disable-with="Changing...">Change Email</.ui_button>
|
||||
</.form>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
|
||||
<.input field={{f, :email}} type="hidden" value={@current_email} />
|
||||
|
||||
<.input field={{f, :password}} type="password" label="New password" required />
|
||||
<.input field={{f, :password_confirmation}} type="password" label="Confirm new password" />
|
||||
<.input
|
||||
field={{f, :current_password}}
|
||||
name="current_password"
|
||||
type="password"
|
||||
label="Current password"
|
||||
id="current_password_for_password"
|
||||
value={@current_password}
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Changing...">Change Password</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<.ui_card>
|
||||
<.ui_card_header>
|
||||
<.ui_card_title>Password</.ui_card_title>
|
||||
<.ui_card_description>Update your password</.ui_card_description>
|
||||
</.ui_card_header>
|
||||
<.ui_card_content>
|
||||
<.form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/log_in?_action=password_updated"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.ui_input field={@password_form[:password]} type="password" label="New password" required />
|
||||
<.ui_input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
/>
|
||||
<.ui_input
|
||||
field={@password_form[:current_password]}
|
||||
name="current_password"
|
||||
type="password"
|
||||
label="Current password"
|
||||
id="current_password_for_password"
|
||||
value={@current_password}
|
||||
required
|
||||
/>
|
||||
<.ui_button type="submit" phx-disable-with="Changing...">Change Password</.ui_button>
|
||||
</.form>
|
||||
</.ui_card_content>
|
||||
</.ui_card>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -85,30 +195,58 @@ defmodule MalarkeyWeb.UserSettingsLive do
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_user
|
||||
email_changeset = Accounts.change_user_email(user)
|
||||
password_changeset = Accounts.change_user_password(user)
|
||||
profile_changeset = Accounts.change_user_profile(user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_password, nil)
|
||||
|> assign(:email_form_current_password, nil)
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
|> assign(:profile_form, to_form(profile_changeset))
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_profile", %{"user" => user_params}, socket) do
|
||||
profile_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_profile(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, profile_form: profile_form)}
|
||||
end
|
||||
|
||||
def handle_event("update_profile", %{"user" => user_params}, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, user_params) do
|
||||
{:ok, _user} ->
|
||||
info = "Profile updated successfully."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> assign(:profile_form, to_form(Accounts.change_user_profile(socket.assigns.current_user)))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :profile_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_email", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
email_changeset = Accounts.change_user_email(socket.assigns.current_user, user_params)
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
email_changeset: Map.put(email_changeset, :action, :validate),
|
||||
email_form_current_password: password
|
||||
)
|
||||
email_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_email(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, socket}
|
||||
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", params, socket) do
|
||||
@@ -124,21 +262,23 @@ defmodule MalarkeyWeb.UserSettingsLive do
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, put_flash(socket, :info, info)}
|
||||
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :email_changeset, Map.put(changeset, :action, :insert))}
|
||||
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
password_changeset = Accounts.change_user_password(socket.assigns.current_user, user_params)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:password_changeset, Map.put(password_changeset, :action, :validate))
|
||||
|> assign(:current_password, password)}
|
||||
password_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form, current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", params, socket) do
|
||||
@@ -147,15 +287,15 @@ defmodule MalarkeyWeb.UserSettingsLive do
|
||||
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:trigger_submit, true)
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user, user_params))
|
||||
password_form =
|
||||
user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, socket}
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :password_changeset, changeset)}
|
||||
{:noreply, assign(socket, password_form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
defmodule MalarkeyWeb.UserLiveAuth do
|
||||
require Logger
|
||||
import Phoenix.Component
|
||||
alias Malarkey.Accounts
|
||||
alias Accounts.User
|
||||
|
||||
@spec on_mount(:default, any, any, map) ::
|
||||
{:cont, %{:assigns => atom | map, optional(any) => any}}
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
socket = assign_new(socket, :current_user, fn -> get_current_user(session) end)
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
defp get_current_user(session) do
|
||||
with user_token when not is_nil(user_token) <- session["user_token"],
|
||||
%User{} = user <- Accounts.get_user_by_session_token(session["user_token"]) do
|
||||
user
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ defmodule MalarkeyWeb.Router do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, {MalarkeyWeb.Layouts, :root}
|
||||
plug :put_root_layout, html: {MalarkeyWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_user
|
||||
@@ -17,17 +17,72 @@ defmodule MalarkeyWeb.Router do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
# Routes for unauthenticated users
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
live_session :redirect_if_user_is_authenticated,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||
live "/users/register", UserRegistrationLive, :new
|
||||
live "/users/log_in", UserLoginLive, :new
|
||||
live "/users/reset_password", UserForgotPasswordLive, :new
|
||||
live "/users/reset_password/:token", UserResetPasswordLive, :edit
|
||||
end
|
||||
|
||||
post "/users/log_in", UserSessionController, :create
|
||||
end
|
||||
|
||||
# OAuth routes
|
||||
scope "/auth", MalarkeyWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/:provider", AuthController, :request
|
||||
get "/:provider/callback", AuthController, :callback
|
||||
end
|
||||
|
||||
# Routes for authenticated users
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/", TimelineLive.Index, :index
|
||||
live "/compose", TimelineLive.Index, :compose
|
||||
live "/notifications", NotificationLive.Index, :index
|
||||
live "/explore", ExploreLive.Index, :index
|
||||
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
|
||||
live "/settings/profile", ProfileSettingsLive, :edit
|
||||
|
||||
live "/posts/:id", PostLive.Show, :show
|
||||
|
||||
live "/:username", ProfileLive.Index, :index
|
||||
live "/:username/with_replies", ProfileLive.Index, :with_replies
|
||||
live "/:username/media", ProfileLive.Index, :media
|
||||
live "/:username/likes", ProfileLive.Index, :likes
|
||||
live "/:username/followers", ProfileLive.Followers, :index
|
||||
live "/:username/following", ProfileLive.Following, :index
|
||||
|
||||
live "/:username/status/:id", PostLive.Show, :show
|
||||
end
|
||||
|
||||
delete "/users/log_out", UserSessionController, :delete
|
||||
get "/users/confirm", UserConfirmationInstructionsController, :new
|
||||
post "/users/confirm", UserConfirmationInstructionsController, :create
|
||||
get "/users/confirm/:token", UserConfirmationController, :edit
|
||||
post "/users/confirm/:token", UserConfirmationController, :update
|
||||
end
|
||||
|
||||
# Public routes that work for both authenticated and non-authenticated users
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through :browser
|
||||
|
||||
live "/", PostLive.Index, :index
|
||||
live "/posts", PostLive.Index, :index
|
||||
live "/posts/new", PostLive.Index, :new
|
||||
live "/posts/:id/like", PostLive.Index, :like
|
||||
live "/posts/:id/edit", PostLive.Index, :edit
|
||||
|
||||
live "/posts/:id", PostLive.Show, :show
|
||||
live "/posts/:id/show/edit", PostLive.Show, :edit
|
||||
live_session :public,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :mount_current_user}] do
|
||||
live "/about", PageLive.About, :index
|
||||
live "/explore/public", ExploreLive.Public, :index
|
||||
end
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
@@ -51,42 +106,4 @@ defmodule MalarkeyWeb.Router do
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
live_session :redirect_if_user_is_authenticated,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||
live "/users/register", UserRegistrationLive, :new
|
||||
live "/users/log_in", UserLoginLive, :new
|
||||
live "/users/reset_password", UserForgotPasswordLive, :new
|
||||
live "/users/reset_password/:token", UserResetPasswordLive, :edit
|
||||
end
|
||||
|
||||
post "/users/log_in", UserSessionController, :create
|
||||
end
|
||||
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", MalarkeyWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
delete "/users/log_out", UserSessionController, :delete
|
||||
|
||||
live_session :current_user,
|
||||
on_mount: [{MalarkeyWeb.UserAuth, :mount_current_user}] do
|
||||
live "/users/confirm/:token", UserConfirmationLive, :edit
|
||||
live "/users/confirm", UserConfirmationInstructionsLive, :new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ defmodule MalarkeyWeb.Telemetry do
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_join.duration",
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule MalarkeyWeb.UserAuth do
|
||||
use MalarkeyWeb, :verified_routes
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
@@ -80,7 +81,7 @@ defmodule MalarkeyWeb.UserAuth do
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: "/")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -90,7 +91,7 @@ defmodule MalarkeyWeb.UserAuth do
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user || nil)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
@@ -133,6 +134,7 @@ defmodule MalarkeyWeb.UserAuth do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
on_mount {MalarkeyWeb.UserAuth, :mount_current_user}
|
||||
|
||||
...
|
||||
end
|
||||
|
||||
@@ -172,15 +174,11 @@ defmodule MalarkeyWeb.UserAuth do
|
||||
end
|
||||
|
||||
defp mount_current_user(session, socket) do
|
||||
case session do
|
||||
%{"user_token" => user_token} ->
|
||||
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
end)
|
||||
|
||||
%{} ->
|
||||
Phoenix.Component.assign_new(socket, :current_user, fn -> nil end)
|
||||
end
|
||||
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||
if user_token = session["user_token"] do
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
62
mix.exs
62
mix.exs
@@ -19,7 +19,7 @@ defmodule Malarkey.MixProject do
|
||||
def application do
|
||||
[
|
||||
mod: {Malarkey.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
extra_applications: [:logger, :runtime_tools, :os_mon]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -32,26 +32,48 @@ defmodule Malarkey.MixProject do
|
||||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:phoenix, "~> 1.7.0-rc.0", override: true},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:ecto_sql, "~> 3.6"},
|
||||
{:phoenix, "~> 1.7.18"},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:phoenix_html, "~> 3.0"},
|
||||
{:phoenix_html, "~> 4.1"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 0.18.3"},
|
||||
{:phoenix_live_view, "~> 1.0.0"},
|
||||
{:floki, ">= 0.30.0", only: :test},
|
||||
{:phoenix_live_dashboard, "~> 0.7.2"},
|
||||
{:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
|
||||
{:swoosh, "~> 1.3"},
|
||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
|
||||
{:heroicons,
|
||||
github: "tailwindlabs/heroicons",
|
||||
tag: "v2.1.1",
|
||||
sparse: "optimized",
|
||||
app: false,
|
||||
compile: false,
|
||||
depth: 1},
|
||||
{:swoosh, "~> 1.5"},
|
||||
{:finch, "~> 0.13"},
|
||||
{:telemetry_metrics, "~> 0.6"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.20"},
|
||||
{:gettext, "~> 0.26"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:plug_cowboy, "~> 2.5"},
|
||||
{:ex_heroicons, "~> 2.0.0"}
|
||||
{:dns_cluster, "~> 0.1.1"},
|
||||
{:bandit, "~> 1.5"},
|
||||
# Authentication
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:pbkdf2_elixir, "~> 2.0"},
|
||||
# OAuth providers
|
||||
{:ueberauth, "~> 0.10"},
|
||||
{:ueberauth_google, "~> 0.10"},
|
||||
{:ueberauth_github, "~> 0.8"},
|
||||
{:ueberauth_twitter, "~> 0.4"},
|
||||
{:ueberauth_identity, "~> 0.4"},
|
||||
# Image upload
|
||||
{:ex_aws, "~> 2.5"},
|
||||
{:ex_aws_s3, "~> 2.4"},
|
||||
{:sweet_xml, "~> 0.7"},
|
||||
# Utilities
|
||||
{:timex, "~> 3.7"},
|
||||
{:slugify, "~> 1.3"}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -63,11 +85,17 @@ defmodule Malarkey.MixProject do
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup"],
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
|
||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||
"assets.build": ["tailwind malarkey", "esbuild malarkey"],
|
||||
"assets.deploy": [
|
||||
"tailwind malarkey --minify",
|
||||
"esbuild malarkey --minify",
|
||||
"phx.digest"
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
112
mix.lock
112
mix.lock
@@ -1,47 +1,69 @@
|
||||
%{
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
|
||||
"castore": {:hex, :castore, "0.1.19", "a2c3e46d62b7f3aa2e6f88541c21d7400381e53704394462b9fd4f06f6d42bb6", [:mix], [], "hexpm", "e96e0161a5dc82ef441da24d5fa74aefc40d920f3a6645d15e1f9f3e66bb2109"},
|
||||
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
|
||||
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
|
||||
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
|
||||
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
|
||||
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
|
||||
"ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
|
||||
"esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"},
|
||||
"ex_heroicons": {:hex, :ex_heroicons, "2.0.0", "701ba2a314c0ff542d8e44486fbf482d29700bfccc291ee189b0154789dece54", [:mix], [{:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:surface, "~> 0.7", [hex: :surface, repo: "hexpm", optional: true]}], "hexpm", "029fb3bab5d45bf3777113733cfe944dc3607015d69a15a3ba87e321457e95ac"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"finch": {:hex, :finch, "0.13.0", "c881e5460ec563bf02d4f4584079e62201db676ed4c0ef3e59189331c4eddf7b", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49957dcde10dcdc042a123a507a9c5ec5a803f53646d451db2f7dea696fba6cc"},
|
||||
"floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
|
||||
"gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"},
|
||||
"heroicons": {:hex, :heroicons, "0.5.1", "cca0dcca07af5f74d8a7d111e40418d3615d65e6773c0ea10e20cef070fd30aa", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4b096d0a1d50e9054df9b12cc637c9f65c3972ff086791d3f2d1846f0653117e"},
|
||||
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
||||
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
|
||||
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
|
||||
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
|
||||
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.0-rc.0", "8e328572f496b5170e879da94baa57c5f878f354d50eac052c9a7c6d57c2cf54", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ed503f6c55184afc0a453e44e6ab2a09f014f59b7fdd682313fdc52ec2f82859"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.0", "4fe222c0be55fdc3f9c711e24955fc42a7cd9b7a2f5f406f2580a567c335a573", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "bebf0fc2d2113b61cb5968f585367234b7b4c21d963d691de7b4b2dc6cdaae6f"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.3", "2e3d009422addf8b15c3dccc65ce53baccbe26f7cfd21d264680b5867789a9c1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8845177a866e017dcb7083365393c8f00ab061b8b6b2bda575891079dce81b2"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
|
||||
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
|
||||
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"swoosh": {:hex, :swoosh, "1.8.2", "af9a22ab2c0d20b266f61acca737fa11a121902de9466a39e91bacdce012101c", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d058ba750eafadb6c09a84a352c14c5d1eeeda6e84945fcc95785b7f3067b7db"},
|
||||
"tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"},
|
||||
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
|
||||
"websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"},
|
||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
|
||||
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
|
||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
|
||||
"ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_aws": {:hex, :ex_aws, "2.6.0", "346e87e35e5df0b3c016a96fb30adf6001de102981a71648dfc3ce3ad04765af", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "30729ee9cbaacda674a4e4260d74206fa89bcd712267c4eaf42a0fc34592c0b3"},
|
||||
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.8", "5ee7407bc8252121ad28fba936b3b293f4ecef93753962351feb95b8a66096fa", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "84e512ca2e0ae6a6c497036dff06d4493ffb422cfe476acc811d7c337c16691c"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"},
|
||||
"oauther": {:hex, :oauther, "1.3.0", "82b399607f0ca9d01c640438b34d74ebd9e4acd716508f868e864537ecdb1f76", [:mix], [], "hexpm", "78eb888ea875c72ca27b0864a6f550bc6ee84f2eeca37b093d3d833fbcaec04e"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||
"pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.3.1", "073866b593887365d0ff50bb806d860a50f454bcda49b5b6f4658c9173c53889", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "ab4da7db8aeb2db20e02a1d416cbb46d0690658aafb4396878acef8748c9c319"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||
"timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"},
|
||||
"tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
|
||||
"ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"},
|
||||
"ueberauth_github": {:hex, :ueberauth_github, "0.8.3", "1c478629b4c1dae446c68834b69194ad5cead3b6c67c913db6fdf64f37f0328f", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "ae0ab2879c32cfa51d7287a48219b262bfdab0b7ec6629f24160564247493cc6"},
|
||||
"ueberauth_google": {:hex, :ueberauth_google, "0.12.1", "90cf49743588193334f7a00da252f92d90bfd178d766c0e4291361681fafec7d", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "7f7deacd679b2b66e3bffb68ecc77aa1b5396a0cbac2941815f253128e458c38"},
|
||||
"ueberauth_identity": {:hex, :ueberauth_identity, "0.4.2", "1ef48b37428d225a2eb0cc453b0d446440d8f62c70dbbfef675ed923986136f2", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "134354bc3da3ece4333f3611fbe283372134b19b2ed8a3d7f43554c6102c4bff"},
|
||||
"ueberauth_twitter": {:hex, :ueberauth_twitter, "0.4.1", "92f88b1ad50322cdda719b439bb7f93b225dc0315723117bc25c782e627c8f33", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:oauther, "~> 1.1", [hex: :oauther, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "83ca8ea3e1a3f976f1adbebfb323b9ebf53af453fbbf57d0486801a303b16065"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
## Run `mix gettext.extract` to bring this file up to
|
||||
## date. Leave `msgstr`s empty as changing them here has no
|
||||
## effect: edit them in PO (`.po`) files instead.
|
||||
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:users) do
|
||||
add :email, :citext, null: false
|
||||
add :hashed_password, :string, null: false
|
||||
add :confirmed_at, :naive_datetime
|
||||
add :username, :string
|
||||
add :fullname, :string
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
|
||||
create table(:users_tokens) do
|
||||
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreatePosts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:posts) do
|
||||
add :body, :string
|
||||
add :user_id, references(:users, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
defmodule Malarkey.Repo.Migrations.AddUserLikes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:user_likes, primary_key: false) do
|
||||
add(:post_id, references(:posts, on_delete: :delete_all), primary_key: true)
|
||||
add(:user_id, references(:users, on_delete: :delete_all), primary_key: true)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create(index(:user_likes, [:post_id]))
|
||||
create(index(:user_likes, [:user_id]))
|
||||
|
||||
create(unique_index(:user_likes, [:user_id, :post_id], name: :user_likes_unique_index))
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
defmodule Malarkey.Repo.Migrations.AddUserDislikes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:user_dislikes, primary_key: false) do
|
||||
add(:post_id, references(:posts, on_delete: :delete_all), primary_key: true)
|
||||
add(:user_id, references(:users, on_delete: :delete_all), primary_key: true)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create(index(:user_dislikes, [:post_id]))
|
||||
create(index(:user_dislikes, [:user_id]))
|
||||
|
||||
create(unique_index(:user_dislikes, [:user_id, :post_id], name: :user_dislikes_unique_index))
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
defmodule Malarkey.Repo.Migrations.AddUserReposts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:user_reposts, primary_key: false) do
|
||||
add(:post_id, references(:posts, on_delete: :delete_all), primary_key: true)
|
||||
add(:user_id, references(:users, on_delete: :delete_all), primary_key: true)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create(index(:user_reposts, [:post_id]))
|
||||
create(index(:user_reposts, [:user_id]))
|
||||
|
||||
create(unique_index(:user_reposts, [:user_id, :post_id], name: :user_reposts_unique_index))
|
||||
end
|
||||
end
|
||||
28
priv/repo/migrations/20251029000001_create_users.exs
Normal file
28
priv/repo/migrations/20251029000001_create_users.exs
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateUsers do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:users, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :string
|
||||
add :username, :string, null: false
|
||||
add :display_name, :string
|
||||
add :bio, :text
|
||||
add :location, :string
|
||||
add :website, :string
|
||||
add :avatar_url, :string
|
||||
add :header_url, :string
|
||||
add :hashed_password, :string
|
||||
add :verified, :boolean, default: false
|
||||
add :followers_count, :integer, default: 0
|
||||
add :following_count, :integer, default: 0
|
||||
add :posts_count, :integer, default: 0
|
||||
add :confirmed_at, :naive_datetime
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
create unique_index(:users, [:username])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Users tokens for sessions, email confirmation, password reset
|
||||
create table(:users_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
|
||||
# OAuth identities table
|
||||
create table(:oauth_identities, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :provider, :string, null: false
|
||||
add :provider_uid, :string, null: false
|
||||
add :provider_email, :string
|
||||
add :provider_login, :string
|
||||
add :provider_token, :text
|
||||
add :provider_meta, :map
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:oauth_identities, [:user_id])
|
||||
create unique_index(:oauth_identities, [:provider, :provider_uid])
|
||||
end
|
||||
end
|
||||
27
priv/repo/migrations/20251029000003_create_posts.exs
Normal file
27
priv/repo/migrations/20251029000003_create_posts.exs
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreatePosts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:posts, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :content, :text, null: false
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :reply_to_id, references(:posts, type: :binary_id, on_delete: :nilify_all)
|
||||
add :repost_of_id, references(:posts, type: :binary_id, on_delete: :delete_all)
|
||||
add :quote_post_id, references(:posts, type: :binary_id, on_delete: :nilify_all)
|
||||
add :media_urls, {:array, :string}, default: []
|
||||
add :likes_count, :integer, default: 0
|
||||
add :reposts_count, :integer, default: 0
|
||||
add :replies_count, :integer, default: 0
|
||||
add :views_count, :integer, default: 0
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:posts, [:user_id])
|
||||
create index(:posts, [:reply_to_id])
|
||||
create index(:posts, [:repost_of_id])
|
||||
create index(:posts, [:quote_post_id])
|
||||
create index(:posts, [:inserted_at])
|
||||
end
|
||||
end
|
||||
17
priv/repo/migrations/20251029000004_create_likes.exs
Normal file
17
priv/repo/migrations/20251029000004_create_likes.exs
Normal file
@@ -0,0 +1,17 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateLikes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:likes, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :post_id, references(:posts, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:likes, [:user_id])
|
||||
create index(:likes, [:post_id])
|
||||
create unique_index(:likes, [:user_id, :post_id])
|
||||
end
|
||||
end
|
||||
20
priv/repo/migrations/20251029000005_create_follows.exs
Normal file
20
priv/repo/migrations/20251029000005_create_follows.exs
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateFollows do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:follows, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :follower_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :following_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:follows, [:follower_id])
|
||||
create index(:follows, [:following_id])
|
||||
create unique_index(:follows, [:follower_id, :following_id])
|
||||
|
||||
# Ensure users can't follow themselves
|
||||
create constraint(:follows, :cannot_follow_self, check: "follower_id != following_id")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Malarkey.Repo.Migrations.AddMediaTypesToPosts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:posts) do
|
||||
add :media_types, {:array, :string}, default: []
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user