Compare commits

...

6 Commits

Author SHA1 Message Date
Fergal Moran
18fe2cecd1 Add back the needed privs 2025-10-31 21:29:35 +00:00
Fergal Moran
4590c492cb Ignore the privs 2025-10-31 21:27:50 +00:00
Fergal Moran
01af5992be Merge branch '@feature/new_malarkey' into develop 2025-10-31 21:26:06 +00:00
Fergal Moran
9e2c740041 Initial new malarkey 2025-10-31 21:25:59 +00:00
Fergal Moran
21aee5be2c Out with the old 2025-10-29 18:31:00 +00:00
Fergal Moran
d780d59442 Enough of this malarkey 2025-10-29 18:29:57 +00:00
124 changed files with 5870 additions and 3843 deletions

View File

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

View File

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

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

@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

12
.idea/malarkey.iml generated
View File

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

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

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

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

View File

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

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

@@ -1,2 +1,119 @@
# malarkey
Meaningless talk &amp; 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

View File

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

View File

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

@@ -0,0 +1,6 @@
{
"name": "assets",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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})
})
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, [])

View 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

View File

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

View File

@@ -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/*"

View File

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

View File

@@ -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">&rarr;</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">&rarr;</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">&rarr;</span>
</.link>
<% end %>
</div>
</div>
</header>
<%= @inner_content %>
<body class="bg-background text-foreground antialiased">
{@inner_content}
</body>
</html>

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

@@ -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/*"

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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"},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View 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

View File

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