Initial new malarkey

This commit is contained in:
Fergal Moran
2025-10-31 21:25:59 +00:00
parent 21aee5be2c
commit 9e2c740041
112 changed files with 8988 additions and 0 deletions

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=

6
.formatter.exs Normal file
View File

@@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
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
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# 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

76
assets/css/app.css Normal file
View File

@@ -0,0 +1,76 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* 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;
}

193
assets/js/app.js Normal file
View File

@@ -0,0 +1,193 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
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, {
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.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

6
assets/package-lock.json generated Normal file
View File

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

119
assets/tailwind.config.js Normal file
View File

@@ -0,0 +1,119 @@
// See the Tailwind configuration guide for advanced usage
// https://tailwindcss.com/docs/configuration
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = {
content: [
"./js/**/*.js",
"../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"),
// 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 &"])),
// 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})
})
]
}

165
assets/vendor/topbar.js vendored Normal file
View File

@@ -0,0 +1,165 @@
/**
* @license MIT
* topbar 2.0.0, 2023-02-04
* https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
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) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

98
config/config.exs Normal file
View File

@@ -0,0 +1,98 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :malarkey,
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: "SH8++5n5"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :malarkey, Malarkey.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
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__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "3.4.3",
malarkey: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
--output=../priv/static/assets/app.css
),
cd: Path.expand("../assets", __DIR__)
]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# 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"

85
config/dev.exs Normal file
View File

@@ -0,0 +1,85 @@
import Config
# Configure your database
config :malarkey, Malarkey.Repo,
username: "postgres",
password: "hackme",
hostname: "localhost",
database: "malarkey_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# 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.
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "VAjY0tHhesCtCCj8LPSuVu9hdCofA63uyNJT6PAwcGHxlHYTUMDax203Bbm7YD0o",
watchers: [
esbuild: {Esbuild, :install_and_run, [:malarkey, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:malarkey, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Watch static and templates for browser reloading.
config :malarkey, MalarkeyWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/malarkey_web/(controllers|live|components)/.*(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
config :malarkey, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
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

20
config/prod.exs Normal file
View File

@@ -0,0 +1,20 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# 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: 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
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

117
config/runtime.exs Normal file
View File

@@ -0,0 +1,117 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/malarkey start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :malarkey, MalarkeyWeb.Endpoint, server: true
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :malarkey, Malarkey.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
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/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
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :malarkey, MalarkeyWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "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 config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :malarkey, MalarkeyWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :malarkey, Malarkey.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

37
config/test.exs Normal file
View File

@@ -0,0 +1,37 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :malarkey, Malarkey.Repo,
username: "postgres",
password: "hackme",
hostname: "localhost",
database: "malarkey_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
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: "sXXdDtdo1Pux/ImB+2oaTw2XwqA0vLUnPs6Pf86i0VmYRAR518hQJ5tktY7DEn6+",
server: false
# 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
config :swoosh, :api_client, false
# Print only warnings and errors during test
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

9
lib/malarkey.ex Normal file
View File

@@ -0,0 +1,9 @@
defmodule Malarkey do
@moduledoc """
Malarkey keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

474
lib/malarkey/accounts.ex Normal file
View File

@@ -0,0 +1,474 @@
defmodule Malarkey.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Malarkey.Repo
alias Malarkey.Accounts.{User, UserToken, OAuthIdentity}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
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
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
end
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}) do
User.email_changeset(user, attrs, validate_email: false)
end
@doc """
Emulates that the email will change without actually changing
it in the database.
## Examples
iex> apply_user_email(user, "valid password", %{email: ...})
{:ok, %User{}}
iex> apply_user_email(user, "invalid password", %{email: ...})
{:error, %Ecto.Changeset{}}
"""
def apply_user_email(user, password, attrs) do
user
|> User.email_changeset(attrs)
|> User.validate_current_password(password)
|> Ecto.Changeset.apply_action(:update)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%User{} = user <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, user.email, context)) do
:ok
else
_ -> :error
end
end
defp user_email_multi(user, email, context) do
changeset =
user
|> User.email_changeset(%{email: email})
|> User.confirm_changeset()
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
end
@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}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) 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))
{:ok, %{to: user.email, body: "Update email token: #{encoded_token}"}}
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}) do
User.password_changeset(user, attrs, hash_password: false)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
@doc """
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 """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
:ok
end
## Confirmation
@doc """
Delivers the confirmation email instructions to the given user.
## Examples
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}"))
{:error, :already_confirmed}
"""
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do
{:error, :already_confirmed}
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))
{:ok, %{to: user.email, body: "Confirmation token: #{encoded_token}"}}
end
end
@doc """
Confirms a user by the given token.
If the token matches, the user account is marked as confirmed
and the token is deleted.
"""
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
{:ok, user}
else
_ -> :error
end
end
defp confirm_user_multi(user) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
end
## Reset password
@doc """
Delivers the reset password email to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
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))
{:ok, %{to: user.email, body: "Reset token: #{encoded_token}"}}
end
@doc """
Gets the user by reset password token.
## Examples
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## 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

@@ -0,0 +1,205 @@
defmodule Malarkey.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@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
has_many :posts, Malarkey.Social.Post
has_many :likes, Malarkey.Social.Like
has_many :oauth_identities, Malarkey.Accounts.OAuthIdentity
many_to_many :followers, Malarkey.Accounts.User,
join_through: "follows",
join_keys: [following_id: :id, follower_id: :id]
many_to_many :following, Malarkey.Accounts.User,
join_through: "follows",
join_keys: [follower_id: :id, following_id: :id]
timestamps()
end
@doc """
A user changeset for registration.
It is important to validate the length of both email and password.
Otherwise databases may truncate the email without warnings, which
could lead to unpredictable or insecure behaviour. Long passwords may
also be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
* `:validate_email` - Validates the uniqueness of the email, in case
you don't want to validate the uniqueness of the email (like when
using this changeset for validations on a LiveView form before
submitting the form), this option can be set to `false`.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password, :username, :display_name])
|> validate_email(opts)
|> validate_username()
|> validate_password(opts)
end
defp validate_email(changeset, opts) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> 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: 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")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# 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
end
end
defp maybe_validate_unique_email(changeset, opts) do
if Keyword.get(opts, :validate_email, true) do
changeset
|> unsafe_validate_unique(:email, Malarkey.Repo)
|> unique_constraint(:email)
else
changeset
end
end
@doc """
A user changeset for changing the email.
It requires the email to change otherwise an error is added.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
|> case do
%{changes: %{email: _}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, "did not change")
end
end
@doc """
A user changeset for changing the password.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> 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`.
"""
def confirm_changeset(user) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`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
Pbkdf2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Pbkdf2.no_user_verify()
false
end
@doc """
Validates the current password otherwise adds an error to the changeset.
"""
def validate_current_password(changeset, password) do
if valid_password?(changeset.data, password) do
changeset
else
add_error(changeset, :current_password, "is not valid")
end
end
end

View File

@@ -0,0 +1,170 @@
defmodule Malarkey.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the reset password token expiry short,
# since someone with access to the email may take over the account.
@reset_password_validity_in_days 1
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, Malarkey.Accounts.User
timestamps(updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
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 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, %Malarkey.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: user
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%Malarkey.Accounts.UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within a certain period, depending on the
context. The default contexts supported by this function are either
"confirm", for account confirmation emails, and "reset_password",
for resetting the password. For verifying requests to change the email,
see `verify_change_email_token_query/2`.
"""
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days = days_for_context(context)
query =
from token in token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
defp days_for_context("confirm"), do: @confirm_validity_in_days
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
This is used to validate requests to change the user
email. It is different from `verify_email_token_query/2` precisely because
`verify_email_token_query/2` validates the email has not changed, which is
the starting point by this function.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Returns the token struct for the given token value and context.
"""
def token_and_context_query(token, context) do
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 Malarkey.Accounts.UserToken, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in Malarkey.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@@ -0,0 +1,36 @@
defmodule Malarkey.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
MalarkeyWeb.Telemetry,
Malarkey.Repo,
{DNSCluster, query: Application.get_env(:malarkey, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Malarkey.PubSub},
# Start the Finch HTTP client for sending emails
{Finch, name: Malarkey.Finch},
# Start a worker by calling: Malarkey.Worker.start_link(arg)
# {Malarkey.Worker, arg},
# Start to serve requests, typically the last entry
MalarkeyWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Malarkey.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
MalarkeyWeb.Endpoint.config_change(changed, removed)
:ok
end
end

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

3
lib/malarkey/mailer.ex Normal file
View File

@@ -0,0 +1,3 @@
defmodule Malarkey.Mailer do
use Swoosh.Mailer, otp_app: :malarkey
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

5
lib/malarkey/repo.ex Normal file
View File

@@ -0,0 +1,5 @@
defmodule Malarkey.Repo do
use Ecto.Repo,
otp_app: :malarkey,
adapter: Ecto.Adapters.Postgres
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

117
lib/malarkey_web.ex Normal file
View File

@@ -0,0 +1,117 @@
defmodule MalarkeyWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use MalarkeyWeb, :controller
use MalarkeyWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: MalarkeyWeb.Layouts]
use Gettext, backend: MalarkeyWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {MalarkeyWeb.Layouts, :app}
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: MalarkeyWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import MalarkeyWeb.CoreComponents
import MalarkeyWeb.Components.Avatar
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: MalarkeyWeb.Endpoint,
router: MalarkeyWeb.Router,
statics: MalarkeyWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

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

@@ -0,0 +1,676 @@
defmodule MalarkeyWeb.CoreComponents do
@moduledoc """
Provides core UI components.
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.
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
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
This is a modal.
</.modal>
JS commands may be passed to the `:on_cancel` to configure
the closing/cancel event, for example:
<.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{}
slot :inner_block, required: true
def modal(assigns) do
~H"""
<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"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<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-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
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={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label={gettext("close")}
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
</div>
<div id={"#{@id}-content"}>
{render_slot(@inner_block)}
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
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 :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-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={[
"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 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
{@rest}
>
<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-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 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, 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 multipart),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) 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="mt-2 flex items-center justify-between gap-6">
{render_slot(action, f)}
</div>
</div>
</.form>
"""
end
@doc """
Renders a button.
## Examples
<.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
@doc """
Renders an input with label and error messages.
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={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
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 month number password
range search select tel text textarea time url week)
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(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, 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 ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<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>
<.label for={@id}>{@label}</.label>
<select
id={@id}
name={@name}
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} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div>
<.label for={@id}>{@label}</.label>
<textarea
id={@id}
name={@name}
class={[
"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}
>{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>
<.label for={@id}>{@label}</.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"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>
</div>
"""
end
@doc """
Renders a label.
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
{render_slot(@inner_block)}
</label>
"""
end
@doc """
Generates a generic error message.
"""
slot :inner_block, required: true
def error(assigns) do
~H"""
<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
@doc """
Renders a header with title.
"""
attr :class, :string, default: nil
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<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)}
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc ~S"""
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
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
end
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 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 :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<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={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<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_item.(row))}
</span>
</div>
</td>
<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_item.(row))}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) 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 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>
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts</.back>
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
def back(assigns) do
~H"""
<div class="mt-16">
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.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",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> 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
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# 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
Gettext.dgettext(MalarkeyWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View File

@@ -0,0 +1,14 @@
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/*"
end

View File

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

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<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 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="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

@@ -0,0 +1,24 @@
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_html/404.html.heex
# * lib/malarkey_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View File

@@ -0,0 +1,21 @@
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:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View File

@@ -0,0 +1,9 @@
defmodule MalarkeyWeb.PageController do
use MalarkeyWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end
end

View File

@@ -0,0 +1,10 @@
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/*"
end

View File

@@ -0,0 +1,222 @@
<.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 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<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="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<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="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">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<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="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 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="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"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
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 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="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
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 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="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<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"
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="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>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.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="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://web.libera.chat/#elixir"
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="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
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="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>
Join our Discord server
</a>
</div>
<div>
<a
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="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>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

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

@@ -0,0 +1,26 @@
defmodule MalarkeyWeb.UserSessionController do
use MalarkeyWeb, :controller
alias Malarkey.Accounts
alias MalarkeyWeb.UserAuth
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
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
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log_in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View File

@@ -0,0 +1,57 @@
defmodule MalarkeyWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :malarkey
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_malarkey_key",
signing_salt: "Iwb1Vb0s",
same_site: "Lax"
]
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.
plug Plug.Static,
at: "/",
from: :malarkey,
gzip: false,
only: MalarkeyWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :malarkey
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug MalarkeyWeb.Router
end

View File

@@ -0,0 +1,25 @@
defmodule MalarkeyWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
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:
use Gettext, backend: MalarkeyWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
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

@@ -0,0 +1,343 @@
defmodule MalarkeyWeb.PostLive.Show do
use MalarkeyWeb, :live_view
import MalarkeyWeb.Components.UI
alias Malarkey.Social
import MalarkeyWeb.Components.Posts
@impl true
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(_params, _url, socket) do
{:noreply, socket}
end
@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

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

@@ -0,0 +1,59 @@
defmodule MalarkeyWeb.UserForgotPasswordLive do
use MalarkeyWeb, :live_view
import MalarkeyWeb.Components.UI
alias Malarkey.Accounts
def render(assigns) do
~H"""
<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>
<.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, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset_password/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions to reset your password shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View File

@@ -0,0 +1,74 @@
defmodule MalarkeyWeb.UserLoginLive do
use MalarkeyWeb, :live_view
import MalarkeyWeb.Components.UI
def render(assigns) do
~H"""
<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>
<.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 />
<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 = 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

@@ -0,0 +1,121 @@
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="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>
<.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>
<.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 />
<.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 =
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
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.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_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

@@ -0,0 +1,103 @@
defmodule MalarkeyWeb.UserResetPasswordLive do
use MalarkeyWeb, :live_view
import MalarkeyWeb.Components.UI
alias Malarkey.Accounts
def render(assigns) do
~H"""
<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>
<.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>
<.ui_input field={@form[:password]} type="password" label="New password" required />
<.ui_input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
required
/>
<.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)
form_source =
case socket.assigns do
%{user: user} ->
Accounts.change_user_password(user)
_ ->
%{}
end
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def handle_event("reset_password", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log_in")}
{:error, changeset} ->
{: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_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_user_and_token(socket, %{"token" => token}) do
if user = Accounts.get_user_by_reset_password_token(token) do
assign(socket, user: user, token: token)
else
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")
end
end
defp assign_form(socket, %{} = source) do
assign(socket, :form, to_form(source, as: "user"))
end
end

View File

@@ -0,0 +1,301 @@
defmodule MalarkeyWeb.UserSettingsLive do
use MalarkeyWeb, :live_view
import MalarkeyWeb.Components.UI
alias Malarkey.Accounts
def render(assigns) do
~H"""
<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>
<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>
<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>
<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>
<.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>
<.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>
<.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
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
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(: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_form =
socket.assigns.current_user
|> Accounts.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
user.email,
&url(~p"/users/settings/confirm_email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
{:error, changeset} ->
{: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_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
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

109
lib/malarkey_web/router.ex Normal file
View File

@@ -0,0 +1,109 @@
defmodule MalarkeyWeb.Router do
use MalarkeyWeb, :router
import MalarkeyWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MalarkeyWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api 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_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.
# scope "/api", MalarkeyWeb do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:malarkey, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: MalarkeyWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

View File

@@ -0,0 +1,92 @@
defmodule MalarkeyWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("malarkey.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("malarkey.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("malarkey.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("malarkey.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("malarkey.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {MalarkeyWeb, :count_users, []}
]
end
end

View File

@@ -0,0 +1,228 @@
defmodule MalarkeyWeb.UserAuth do
use MalarkeyWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Malarkey.Accounts
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
# the token expiry itself in UserToken.
@max_age 60 * 60 * 24 * 60
@remember_me_cookie "_malarkey_web_user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
@doc """
Logs the user in.
It renews the session ID and clears the whole session
to avoid fixation attacks. See the renew_session
function to customize this behaviour.
It also sets a `:live_socket_id` key in the session,
so LiveView sessions are identified and automatically
disconnected on log out. The line can be safely removed
if you are not using LiveView.
"""
def log_in_user(conn, user, params \\ %{}) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
defp maybe_write_remember_me_cookie(conn, _token, _params) do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn) do
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn) do
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
MalarkeyWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session
and remember me token.
"""
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)
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule MalarkeyWeb.PageLive do
use MalarkeyWeb, :live_view
on_mount {MalarkeyWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{MalarkeyWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(session, socket)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(session, socket) do
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 """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
@doc """
Used for routes that require the user to be authenticated.
If you want to enforce the user email is confirmed before
they use the application at all, here would be a good place.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log_in")
|> halt()
end
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: ~p"/"
end

101
mix.exs Normal file
View File

@@ -0,0 +1,101 @@
defmodule Malarkey.MixProject do
use Mix.Project
def project do
[
app: :malarkey,
version: "0.1.0",
elixir: "~> 1.14",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Malarkey.Application, []},
extra_applications: [:logger, :runtime_tools, :os_mon]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.7.18"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.0.0"},
{:floki, ">= 0.30.0", only: :test},
{: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, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{: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
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
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.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

69
mix.lock Normal file
View File

@@ -0,0 +1,69 @@
%{
"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

@@ -0,0 +1,112 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

109
priv/gettext/errors.pot Normal file
View File

@@ -0,0 +1,109 @@
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## 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 ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

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

11
priv/repo/seeds.exs Normal file
View File

@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Malarkey.Repo.insert!(%Malarkey.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

BIN
priv/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" 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-.077.057c1.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.728a13 13 0 0 0 1.182 1.106c1.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.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

5
priv/static/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

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