Initial new malarkey
11
.env
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
# Giphy API Configuration
|
||||
GIPHY_API_KEY=iMV61T4eUDWLTvEon2kgGAqkK9LTYmOY
|
||||
|
||||
# OAuth Configuration (existing)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
11
.env.example
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "assets",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
119
assets/tailwind.config.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
27
lib/malarkey/accounts/oauth_identity.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Malarkey.Accounts.OAuthIdentity do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "oauth_identities" do
|
||||
field :provider, :string
|
||||
field :provider_uid, :string
|
||||
field :provider_email, :string
|
||||
field :provider_login, :string
|
||||
field :provider_token, :string
|
||||
field :provider_meta, :map
|
||||
|
||||
belongs_to :user, Malarkey.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(oauth_identity, attrs) do
|
||||
oauth_identity
|
||||
|> cast(attrs, [:provider, :provider_uid, :provider_email, :provider_login, :provider_token, :provider_meta, :user_id])
|
||||
|> validate_required([:provider, :provider_uid])
|
||||
|> unique_constraint([:provider, :provider_uid])
|
||||
end
|
||||
end
|
||||
205
lib/malarkey/accounts/user.ex
Normal 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
|
||||
170
lib/malarkey/accounts/user_token.ex
Normal 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
|
||||
36
lib/malarkey/application.ex
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
||||
defmodule Malarkey.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :malarkey
|
||||
end
|
||||
90
lib/malarkey/media.ex
Normal file
@@ -0,0 +1,90 @@
|
||||
defmodule Malarkey.Media do
|
||||
@moduledoc """
|
||||
Context for handling media uploads to the local filesystem.
|
||||
"""
|
||||
|
||||
@upload_dir "priv/static/uploads"
|
||||
|
||||
@doc """
|
||||
Uploads a file to the local filesystem and returns the public URL.
|
||||
"""
|
||||
def upload_file(file_path, content_type, user_id) do
|
||||
file_name = generate_filename(file_path, user_id)
|
||||
dest_path = Path.join(@upload_dir, file_name)
|
||||
File.mkdir_p!(Path.dirname(dest_path))
|
||||
case File.cp(file_path, dest_path) do
|
||||
:ok -> {:ok, get_public_url(file_name)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Uploads binary data (from paste) to the local filesystem.
|
||||
"""
|
||||
def upload_binary(binary, content_type, user_id, extension \\ ".png") do
|
||||
file_name = generate_binary_filename(user_id, extension)
|
||||
dest_path = Path.join(@upload_dir, file_name)
|
||||
File.mkdir_p!(Path.dirname(dest_path))
|
||||
case File.write(dest_path, binary) do
|
||||
:ok -> {:ok, get_public_url(file_name)}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines media type from content type or URL.
|
||||
"""
|
||||
def get_media_type(content_type) when is_binary(content_type) do
|
||||
cond do
|
||||
String.starts_with?(content_type, "image/") -> "image"
|
||||
String.starts_with?(content_type, "video/") -> "video"
|
||||
true -> "unknown"
|
||||
end
|
||||
end
|
||||
|
||||
def get_media_type(_), do: "unknown"
|
||||
|
||||
@doc """
|
||||
Validates file size (10MB for images, 50MB for videos).
|
||||
"""
|
||||
def validate_file_size(size, type) do
|
||||
max_size = case type do
|
||||
"image" -> 10 * 1024 * 1024 # 10MB
|
||||
"video" -> 50 * 1024 * 1024 # 50MB
|
||||
_ -> 10 * 1024 * 1024
|
||||
end
|
||||
|
||||
if size <= max_size do
|
||||
:ok
|
||||
else
|
||||
{:error, "File too large. Maximum size is #{format_bytes(max_size)}"}
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp generate_filename(file_path, user_id) do
|
||||
extension = Path.extname(file_path)
|
||||
timestamp = DateTime.utc_now() |> DateTime.to_unix()
|
||||
random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
|
||||
"#{user_id}/#{timestamp}_#{random}#{extension}"
|
||||
end
|
||||
|
||||
defp generate_binary_filename(user_id, extension) do
|
||||
timestamp = DateTime.utc_now() |> DateTime.to_unix()
|
||||
random = :crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)
|
||||
"#{user_id}/#{timestamp}_#{random}#{extension}"
|
||||
end
|
||||
|
||||
defp get_public_url(file_name) do
|
||||
"/uploads/#{file_name}"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) do
|
||||
cond do
|
||||
bytes >= 1024 * 1024 -> "#{div(bytes, 1024 * 1024)}MB"
|
||||
bytes >= 1024 -> "#{div(bytes, 1024)}KB"
|
||||
true -> "#{bytes}B"
|
||||
end
|
||||
end
|
||||
end
|
||||
5
lib/malarkey/repo.ex
Normal 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
@@ -0,0 +1,575 @@
|
||||
defmodule Malarkey.Social do
|
||||
@moduledoc """
|
||||
The Social context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Malarkey.Repo
|
||||
|
||||
alias Malarkey.Social.{Post, Like, Follow}
|
||||
alias Malarkey.Accounts.User
|
||||
|
||||
@doc """
|
||||
Returns the list of posts for the home timeline.
|
||||
Includes posts from users that the given user follows.
|
||||
"""
|
||||
def list_timeline_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
following_ids =
|
||||
from(f in Follow,
|
||||
where: f.follower_id == ^user.id,
|
||||
select: f.following_id
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
user_ids = [user.id | following_ids]
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id in ^user_ids,
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :repost_of, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts for a specific user.
|
||||
"""
|
||||
def list_user_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user.id and is_nil(t.repost_of_id),
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts with media for a specific user.
|
||||
"""
|
||||
def list_user_media_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user.id and fragment("cardinality(?) > 0", t.media_urls),
|
||||
order_by: [desc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of posts liked by a specific user.
|
||||
"""
|
||||
def list_user_liked_posts(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(t in Post,
|
||||
join: l in Like,
|
||||
on: l.post_id == t.id,
|
||||
where: l.user_id == ^user.id,
|
||||
order_by: [desc: l.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user, :reply_to, :quote_post]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single post.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Post does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_post!(123)
|
||||
%Post{}
|
||||
|
||||
iex> get_post!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@doc """
|
||||
Gets a single post, raising if not found.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
"""
|
||||
def get_post!(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
Post
|
||||
|> Repo.get!(uuid)
|
||||
|> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
|
||||
:error ->
|
||||
raise Ecto.NoResultsError, queryable: Post
|
||||
end
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Post
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload([:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single post without raising.
|
||||
|
||||
Returns `nil` if the Post does not exist.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_post("123e4567-e89b-12d3-a456-426614174000")
|
||||
%Post{}
|
||||
|
||||
iex> get_post("invalid")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_post(id) when is_binary(id) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} ->
|
||||
case Repo.get(Post, uuid) do
|
||||
nil -> nil
|
||||
post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def get_post(id) do
|
||||
case Repo.get(Post, id) do
|
||||
nil -> nil
|
||||
post -> Repo.preload(post, [:user, :reply_to, :repost_of, :quote_post])
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets post replies.
|
||||
"""
|
||||
def list_post_replies(post, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
|
||||
from(t in Post,
|
||||
where: t.reply_to_id == ^post.id,
|
||||
order_by: [asc: t.inserted_at],
|
||||
limit: ^limit,
|
||||
preload: [:user]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets threaded replies for a post with nested structure.
|
||||
Returns a map of post_id => replies for building thread trees.
|
||||
Accepts both string UUIDs and Ecto.UUID binaries.
|
||||
"""
|
||||
def get_threaded_replies(post_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 500)
|
||||
|
||||
# Convert string UUID to binary format for Postgres
|
||||
uuid = case Ecto.UUID.dump(post_id) do
|
||||
{:ok, binary_uuid} -> binary_uuid
|
||||
:error -> post_id # Already in binary format
|
||||
end
|
||||
|
||||
# Get all replies in the thread recursively
|
||||
query = """
|
||||
WITH RECURSIVE reply_tree AS (
|
||||
-- Base case: direct replies to the post
|
||||
SELECT p.*, 0 as depth, ARRAY[p.id] as path
|
||||
FROM posts p
|
||||
WHERE p.reply_to_id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: replies to replies
|
||||
SELECT p.*, rt.depth + 1, rt.path || p.id
|
||||
FROM posts p
|
||||
INNER JOIN reply_tree rt ON p.reply_to_id = rt.id
|
||||
WHERE NOT p.id = ANY(rt.path) -- Prevent cycles
|
||||
AND rt.depth < 10 -- Limit depth
|
||||
)
|
||||
SELECT * FROM reply_tree ORDER BY path LIMIT $2
|
||||
"""
|
||||
|
||||
result = Ecto.Adapters.SQL.query!(Repo, query, [uuid, limit])
|
||||
|
||||
# Convert results to Post structs and preload associations
|
||||
reply_ids = Enum.map(result.rows, fn row -> Enum.at(row, 0) end)
|
||||
|
||||
from(p in Post,
|
||||
where: p.id in ^reply_ids,
|
||||
preload: [:user, :reply_to]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.group_by(& &1.reply_to_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_post(%{field: value})
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> create_post(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_post(attrs \\ %{}) do
|
||||
result =
|
||||
%Post{}
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, post} ->
|
||||
# Increment user's posts count
|
||||
from(u in User, where: u.id == ^post.user_id)
|
||||
|> Repo.update_all(inc: [posts_count: 1])
|
||||
|
||||
# If it's a reply, increment the parent's replies count
|
||||
if post.reply_to_id do
|
||||
from(t in Post, where: t.id == ^post.reply_to_id)
|
||||
|> Repo.update_all(inc: [replies_count: 1])
|
||||
end
|
||||
|
||||
# Broadcast the new post
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"posts:new",
|
||||
{:new_post, Repo.preload(post, [:user, :reply_to, :quote_post])}
|
||||
)
|
||||
|
||||
{:ok, Repo.preload(post, [:user, :reply_to, :quote_post])}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has reposted a post.
|
||||
"""
|
||||
def reposted_by_user?(user_id, post_id) do
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a repost.
|
||||
"""
|
||||
def create_repost(user_id, post_id) do
|
||||
# Check if already reposted
|
||||
existing =
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|
||||
if existing do
|
||||
{:error, :already_reposted}
|
||||
else
|
||||
result =
|
||||
%Post{}
|
||||
|> Post.repost_changeset(%{user_id: user_id, repost_of_id: post_id})
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, repost} ->
|
||||
# Increment the original post's reposts count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [reposts_count: 1])
|
||||
|
||||
# Increment user's posts count
|
||||
from(u in User, where: u.id == ^user_id)
|
||||
|> Repo.update_all(inc: [posts_count: 1])
|
||||
|
||||
{:ok, Repo.preload(repost, [:user, :repost_of])}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a repost.
|
||||
"""
|
||||
def delete_repost(user_id, post_id) do
|
||||
from(t in Post,
|
||||
where: t.user_id == ^user_id and t.repost_of_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
repost ->
|
||||
Repo.delete(repost)
|
||||
|
||||
# Decrement the original post's reposts count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [reposts_count: -1])
|
||||
|
||||
# Decrement user's posts count
|
||||
from(u in User, where: u.id == ^user_id)
|
||||
|> Repo.update_all(inc: [posts_count: -1])
|
||||
|
||||
{:ok, repost}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_post(post, %{field: new_value})
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> update_post(post, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_post(%Post{} = post, attrs) do
|
||||
post
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a post.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_post(post)
|
||||
{:ok, %Post{}}
|
||||
|
||||
iex> delete_post(post)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_post(%Post{} = post) do
|
||||
result = Repo.delete(post)
|
||||
|
||||
case result do
|
||||
{:ok, deleted_post} ->
|
||||
# Decrement user's posts count
|
||||
from(u in User, where: u.id == ^deleted_post.user_id)
|
||||
|> Repo.update_all(inc: [posts_count: -1])
|
||||
|
||||
{:ok, deleted_post}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking post changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_post(post)
|
||||
%Ecto.Changeset{data: %Post{}}
|
||||
|
||||
"""
|
||||
def change_post(%Post{} = post, attrs \\ %{}) do
|
||||
Post.changeset(post, attrs)
|
||||
end
|
||||
|
||||
## Likes
|
||||
|
||||
@doc """
|
||||
Creates a like.
|
||||
"""
|
||||
def create_like(attrs \\ %{}) do
|
||||
result =
|
||||
%Like{}
|
||||
|> Like.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, like} ->
|
||||
# Increment post's likes count
|
||||
from(t in Post, where: t.id == ^like.post_id)
|
||||
|> Repo.update_all(inc: [likes_count: 1])
|
||||
|
||||
# Broadcast the like
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"post:#{like.post_id}",
|
||||
{:like_added, like}
|
||||
)
|
||||
|
||||
{:ok, like}
|
||||
|
||||
{:error, changeset} ->
|
||||
# If it's a unique constraint error, it means already liked
|
||||
if changeset.errors[:user_id] || changeset.errors[:post_id] do
|
||||
{:error, :already_liked}
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a like.
|
||||
"""
|
||||
def delete_like(user_id, post_id) do
|
||||
from(l in Like,
|
||||
where: l.user_id == ^user_id and l.post_id == ^post_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
like ->
|
||||
Repo.delete(like)
|
||||
|
||||
# Decrement post's likes count
|
||||
from(t in Post, where: t.id == ^post_id)
|
||||
|> Repo.update_all(inc: [likes_count: -1])
|
||||
|
||||
# Broadcast the unlike
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"post:#{post_id}",
|
||||
{:like_removed, like}
|
||||
)
|
||||
|
||||
{:ok, like}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has liked a post.
|
||||
"""
|
||||
def liked_by_user?(user_id, post_id) do
|
||||
Repo.exists?(
|
||||
from l in Like,
|
||||
where: l.user_id == ^user_id and l.post_id == ^post_id
|
||||
)
|
||||
end
|
||||
|
||||
## Follows
|
||||
|
||||
@doc """
|
||||
Creates a follow relationship.
|
||||
"""
|
||||
def create_follow(attrs \\ %{}) do
|
||||
result =
|
||||
%Follow{}
|
||||
|> Follow.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, follow} ->
|
||||
# Increment follower's following count
|
||||
from(u in User, where: u.id == ^follow.follower_id)
|
||||
|> Repo.update_all(inc: [following_count: 1])
|
||||
|
||||
# Increment following's followers count
|
||||
from(u in User, where: u.id == ^follow.following_id)
|
||||
|> Repo.update_all(inc: [followers_count: 1])
|
||||
|
||||
# Broadcast the follow
|
||||
Phoenix.PubSub.broadcast(
|
||||
Malarkey.PubSub,
|
||||
"user:#{follow.following_id}",
|
||||
{:new_follower, follow}
|
||||
)
|
||||
|
||||
{:ok, follow}
|
||||
|
||||
{:error, changeset} ->
|
||||
# If it's a unique constraint error, it means already following
|
||||
if changeset.errors[:follower_id] || changeset.errors[:following_id] do
|
||||
{:error, :already_following}
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a follow relationship.
|
||||
"""
|
||||
def delete_follow(follower_id, following_id) do
|
||||
from(f in Follow,
|
||||
where: f.follower_id == ^follower_id and f.following_id == ^following_id
|
||||
)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
follow ->
|
||||
Repo.delete(follow)
|
||||
|
||||
# Decrement follower's following count
|
||||
from(u in User, where: u.id == ^follower_id)
|
||||
|> Repo.update_all(inc: [following_count: -1])
|
||||
|
||||
# Decrement following's followers count
|
||||
from(u in User, where: u.id == ^following_id)
|
||||
|> Repo.update_all(inc: [followers_count: -1])
|
||||
|
||||
{:ok, follow}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user is following another user.
|
||||
"""
|
||||
def following?(follower_id, following_id) do
|
||||
Repo.exists?(
|
||||
from f in Follow,
|
||||
where: f.follower_id == ^follower_id and f.following_id == ^following_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a list of followers for a user.
|
||||
"""
|
||||
def list_followers(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(u in User,
|
||||
join: f in Follow,
|
||||
on: f.follower_id == u.id,
|
||||
where: f.following_id == ^user.id,
|
||||
order_by: [desc: f.inserted_at],
|
||||
limit: ^limit
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a list of users that a user is following.
|
||||
"""
|
||||
def list_following(user, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 20)
|
||||
|
||||
from(u in User,
|
||||
join: f in Follow,
|
||||
on: f.following_id == u.id,
|
||||
where: f.follower_id == ^user.id,
|
||||
order_by: [desc: f.inserted_at],
|
||||
limit: ^limit
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
24
lib/malarkey/social/follow.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule Malarkey.Social.Follow do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "follows" do
|
||||
belongs_to :follower, Malarkey.Accounts.User
|
||||
belongs_to :following, Malarkey.Accounts.User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(follow, attrs) do
|
||||
follow
|
||||
|> cast(attrs, [:follower_id, :following_id])
|
||||
|> validate_required([:follower_id, :following_id])
|
||||
|> unique_constraint([:follower_id, :following_id])
|
||||
|> check_constraint(:follower_id, name: :cannot_follow_self, message: "cannot follow yourself")
|
||||
|> foreign_key_constraint(:follower_id)
|
||||
|> foreign_key_constraint(:following_id)
|
||||
end
|
||||
end
|
||||
23
lib/malarkey/social/like.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule Malarkey.Social.Like do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "likes" do
|
||||
belongs_to :user, Malarkey.Accounts.User
|
||||
belongs_to :post, Malarkey.Social.Post
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(like, attrs) do
|
||||
like
|
||||
|> cast(attrs, [:user_id, :post_id])
|
||||
|> validate_required([:user_id, :post_id])
|
||||
|> unique_constraint([:user_id, :post_id])
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:post_id)
|
||||
end
|
||||
end
|
||||
93
lib/malarkey/social/post.ex
Normal file
@@ -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
@@ -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
|
||||
53
lib/malarkey_web/components/avatar.ex
Normal file
@@ -0,0 +1,53 @@
|
||||
defmodule MalarkeyWeb.Components.Avatar do
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a user avatar with fallback to initials.
|
||||
|
||||
## Examples
|
||||
|
||||
<.avatar user={@user} size="sm" />
|
||||
<.avatar user={@user} size="md" />
|
||||
<.avatar user={@user} size="lg" />
|
||||
<.avatar user={@user} size="xl" />
|
||||
"""
|
||||
attr :user, :map, required: true, doc: "The user struct containing avatar_url and username"
|
||||
attr :size, :string, default: "md", values: ["sm", "md", "lg", "xl"]
|
||||
attr :class, :string, default: "", doc: "Additional CSS classes"
|
||||
|
||||
def avatar(assigns) do
|
||||
~H"""
|
||||
<%= if @user.avatar_url do %>
|
||||
<img
|
||||
src={@user.avatar_url}
|
||||
alt={@user.username}
|
||||
class={[
|
||||
"rounded-full object-cover",
|
||||
size_class(@size),
|
||||
@class
|
||||
]}
|
||||
/>
|
||||
<% else %>
|
||||
<div class={[
|
||||
"rounded-full flex items-center justify-center font-semibold",
|
||||
"bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300",
|
||||
size_class(@size),
|
||||
@class
|
||||
]}>
|
||||
<%= initial(@user) %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp size_class("sm"), do: "w-8 h-8 text-xs"
|
||||
defp size_class("md"), do: "w-10 h-10 text-sm"
|
||||
defp size_class("lg"), do: "w-12 h-12 text-base"
|
||||
defp size_class("xl"), do: "w-16 h-16 text-xl"
|
||||
|
||||
defp initial(user) do
|
||||
(user.display_name || user.username)
|
||||
|> String.first()
|
||||
|> String.upcase()
|
||||
end
|
||||
end
|
||||
676
lib/malarkey_web/components/core_components.ex
Normal 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
|
||||
14
lib/malarkey_web/components/layouts.ex
Normal 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
|
||||
86
lib/malarkey_web/components/layouts/app.html.heex
Normal 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>
|
||||
17
lib/malarkey_web/components/layouts/root.html.heex
Normal 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>
|
||||
79
lib/malarkey_web/components/media.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule MalarkeyWeb.Components.Media do
|
||||
@moduledoc """
|
||||
Media display components for posts.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :media_urls, :list, required: true
|
||||
attr :media_types, :list, default: []
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def post_media(assigns) do
|
||||
~H"""
|
||||
<div :if={length(@media_urls) > 0} class={["mt-3 rounded-xl overflow-hidden", @class]}>
|
||||
<%= if length(@media_urls) == 1 do %>
|
||||
<.single_media url={Enum.at(@media_urls, 0)} type={Enum.at(@media_types, 0)} />
|
||||
<% else %>
|
||||
<.media_grid media_urls={@media_urls} media_types={@media_types} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :url, :string, required: true
|
||||
attr :type, :string, default: "image"
|
||||
|
||||
defp single_media(assigns) do
|
||||
~H"""
|
||||
<%= case @type do %>
|
||||
<% "video" -> %>
|
||||
<video controls class="w-full max-h-[500px] bg-black">
|
||||
<source src={@url} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<% "gif" -> %>
|
||||
<img src={@url} alt="GIF" class="w-full max-h-[500px] object-contain bg-muted" />
|
||||
<% _ -> %>
|
||||
<img src={@url} alt="Post media" class="w-full max-h-[500px] object-cover" />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :media_urls, :list, required: true
|
||||
attr :media_types, :list, required: true
|
||||
|
||||
defp media_grid(assigns) do
|
||||
assigns = assign(assigns, :media_count, length(assigns.media_urls))
|
||||
|
||||
~H"""
|
||||
<div class={[
|
||||
"grid gap-0.5",
|
||||
@media_count == 2 && "grid-cols-2",
|
||||
@media_count == 3 && "grid-cols-2",
|
||||
@media_count >= 4 && "grid-cols-2"
|
||||
]}>
|
||||
<%= for {url, index} <- Enum.with_index(@media_urls) do %>
|
||||
<%= if index < 4 do %>
|
||||
<div class={[
|
||||
"relative aspect-video overflow-hidden bg-muted",
|
||||
@media_count == 3 && index == 0 && "row-span-2"
|
||||
]}>
|
||||
<%= if Enum.at(@media_types, index) == "video" do %>
|
||||
<video controls class="w-full h-full object-cover">
|
||||
<source src={url} type="video/mp4" />
|
||||
</video>
|
||||
<% else %>
|
||||
<img src={url} alt={"Media #{index + 1}"} class="w-full h-full object-cover" />
|
||||
<% end %>
|
||||
<%= if @media_count > 4 && index == 3 do %>
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span class="text-white text-3xl font-bold">+<%= @media_count - 4 %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
165
lib/malarkey_web/components/post_composer.ex
Normal file
@@ -0,0 +1,165 @@
|
||||
defmodule MalarkeyWeb.Components.PostComposer do
|
||||
@moduledoc """
|
||||
Reusable post composer component for creating new posts and replies.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Avatar
|
||||
|
||||
attr :current_user, :map, required: true
|
||||
attr :body, :string, default: ""
|
||||
attr :char_count, :integer, default: 0
|
||||
attr :uploaded_files, :list, default: []
|
||||
attr :uploads, :map, required: true
|
||||
attr :submit_event, :string, required: true
|
||||
attr :update_event, :string, required: true
|
||||
attr :placeholder, :string, default: "What's happening?"
|
||||
attr :reply_to, :map, default: nil
|
||||
attr :show_cancel, :boolean, default: false
|
||||
attr :cancel_event, :string, default: nil
|
||||
|
||||
def post_composer(assigns) do
|
||||
# Generate unique IDs based on whether it's a reply or not
|
||||
assigns = assign(assigns, :form_id, if(assigns.reply_to, do: "reply-composer-form", else: "post-composer-form"))
|
||||
assigns = assign(assigns, :textarea_id, if(assigns.reply_to, do: "reply-textarea", else: "post-textarea"))
|
||||
|
||||
~H"""
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={@current_user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- Reply context -->
|
||||
<div :if={@reply_to} class="mb-2 text-sm text-muted-foreground">
|
||||
Replying to <span class="text-primary">@<%= @reply_to.user.username %></span>
|
||||
</div>
|
||||
|
||||
<form id={@form_id} phx-submit={@submit_event}>
|
||||
<textarea
|
||||
name="body"
|
||||
phx-keyup={@update_event}
|
||||
phx-hook="PasteImage"
|
||||
id={@textarea_id}
|
||||
placeholder={@placeholder}
|
||||
rows="3"
|
||||
class="flex min-h-[60px] w-full border-0 bg-transparent px-0 py-0 text-lg placeholder:text-muted-foreground focus:outline-none focus-visible:outline-none focus:ring-0 resize-none"
|
||||
><%= @body %></textarea>
|
||||
|
||||
<!-- Media Previews -->
|
||||
<div :if={length(@uploaded_files) > 0 || length(@uploads.media.entries) > 0} class="grid grid-cols-2 gap-2 mt-3">
|
||||
<!-- Uploaded files (GIFs, pasted images) -->
|
||||
<div
|
||||
:for={{url, _type} <- Enum.with_index(@uploaded_files)}
|
||||
class="relative overflow-hidden rounded-lg aspect-video bg-muted"
|
||||
>
|
||||
<img src={elem(url, 0)} alt="Preview" class="object-cover w-full h-full" />
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_media"
|
||||
phx-value-index={elem(url, 1)}
|
||||
class="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-full text-white"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- LiveView uploads -->
|
||||
<div
|
||||
:for={entry <- @uploads.media.entries}
|
||||
class="relative overflow-hidden rounded-lg aspect-video bg-muted"
|
||||
>
|
||||
<.live_img_preview entry={entry} class="object-cover w-full h-full" />
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
class="absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 rounded-full text-white"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="relative flex items-center space-x-1">
|
||||
<!-- Image/Video Upload -->
|
||||
<label class="flex items-center justify-center p-2 transition-colors rounded-full cursor-pointer hover:bg-accent text-primary">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<.live_file_input upload={@uploads.media} class="hidden" />
|
||||
</label>
|
||||
|
||||
<!-- GIF Button -->
|
||||
<button
|
||||
type="button"
|
||||
phx-click="open_giphy"
|
||||
class="flex items-center justify-center p-2 transition-colors rounded-full hover:bg-accent text-primary"
|
||||
>
|
||||
<span class="text-sm font-bold">GIF</span>
|
||||
</button>
|
||||
|
||||
<!-- Emoji Button -->
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_emoji_picker"
|
||||
class="flex items-center justify-center p-2 transition-colors rounded-full hover:bg-accent text-primary"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={[
|
||||
"text-sm",
|
||||
@char_count > 280 && "text-destructive",
|
||||
@char_count <= 280 && "text-muted-foreground"
|
||||
]}>
|
||||
<%= @char_count %> / 280
|
||||
</span>
|
||||
|
||||
<button
|
||||
:if={@show_cancel}
|
||||
type="button"
|
||||
phx-click={@cancel_event}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors rounded-md hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<.ui_button type="submit" disabled={@char_count == 0 || @char_count > 280}>
|
||||
Post
|
||||
</.ui_button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
283
lib/malarkey_web/components/posts.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule MalarkeyWeb.Components.Posts do
|
||||
@moduledoc """
|
||||
Reusable post display components.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Media
|
||||
import MalarkeyWeb.Components.Avatar
|
||||
|
||||
alias Malarkey.Social
|
||||
|
||||
# Import for verified routes
|
||||
use MalarkeyWeb, :verified_routes
|
||||
|
||||
attr :post, :map, required: true
|
||||
attr :current_user, :map, required: true
|
||||
attr :dom_id, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def post_card(assigns) do
|
||||
assigns = assign(assigns, :card_class, Enum.join(["transition-all duration-300 ease-out hover:shadow-md animate-slide-in cursor-pointer", assigns[:class] || ""], " "))
|
||||
|
||||
~H"""
|
||||
<.ui_card
|
||||
id={@dom_id}
|
||||
class={@card_class}
|
||||
phx-hook="PostCard"
|
||||
data-post-url={~p"/posts/#{@post.id}"}
|
||||
>
|
||||
<.ui_card_content class="pt-6">
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="block transition-opacity hover:opacity-80">
|
||||
<.avatar user={@post.user} size="lg" />
|
||||
</.link>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="font-semibold transition-colors text-foreground hover:underline">
|
||||
<%= @post.user.display_name || @post.user.username %>
|
||||
</.link>
|
||||
<.link navigate={~p"/#{@post.user.username}"} class="transition-colors text-muted-foreground hover:underline">
|
||||
@<%= @post.user.username %>
|
||||
</.link>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<%= Timex.format!(@post.inserted_at, "{relative}", :relative) %>
|
||||
</span>
|
||||
</div>
|
||||
<p :if={@post.body && String.trim(@post.body) != "" && String.trim(@post.body) != " "} class="mt-2 whitespace-pre-wrap text-foreground">
|
||||
<%= @post.body %>
|
||||
</p>
|
||||
|
||||
<!-- Media Display -->
|
||||
<.post_media
|
||||
:if={length(@post.media_urls || []) > 0}
|
||||
media_urls={@post.media_urls}
|
||||
media_types={@post.media_types || []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.ui_card_content>
|
||||
|
||||
<!-- Post Actions - outside the link -->
|
||||
<div class="flex items-center gap-6 mt-4 px-4 pb-2">
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="open_reply_modal"
|
||||
phx-value-id={@post.id}
|
||||
class="gap-2 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.replies_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={
|
||||
if reposted_by_user?(@post, @current_user),
|
||||
do: "delete_repost",
|
||||
else: "repost"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
reposted_by_user?(@post, @current_user) && "text-green-600",
|
||||
!reposted_by_user?(@post, @current_user) && "text-muted-foreground hover:text-green-600"
|
||||
]}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.reposts_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={
|
||||
if liked_by_user?(@post, @current_user),
|
||||
do: "unlike_post",
|
||||
else: "like_post"
|
||||
}
|
||||
phx-value-id={@post.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
liked_by_user?(@post, @current_user) && "text-red-500 hover:text-red-600",
|
||||
!liked_by_user?(@post, @current_user) &&
|
||||
"text-muted-foreground hover:text-red-500"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
class={[
|
||||
"w-4 h-4",
|
||||
liked_by_user?(@post, @current_user) && "fill-current"
|
||||
]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @post.likes_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button variant="ghost" size="sm" class="text-muted-foreground hover:text-primary">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
:if={@post.user_id == @current_user.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="delete_post"
|
||||
phx-value-id={@post.id}
|
||||
data-confirm="Are you sure you want to delete this post?"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</.ui_button>
|
||||
</div>
|
||||
</.ui_card>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :reply, :map, required: true
|
||||
attr :current_user, :map, required: true
|
||||
attr :level, :integer, default: 0
|
||||
attr :parent_post, :map, default: nil
|
||||
|
||||
def threaded_reply(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"relative",
|
||||
@level > 0 && "ml-12"
|
||||
]}>
|
||||
<!-- Thread line connector -->
|
||||
<div :if={@level > 0} class="absolute left-0 top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700 -ml-6"></div>
|
||||
|
||||
<div class="flex space-x-3 py-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="block transition-opacity hover:opacity-80">
|
||||
<.avatar user={@reply.user} size="md" />
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="font-semibold transition-colors hover:underline">
|
||||
<%= @reply.user.display_name || @reply.user.username %>
|
||||
</.link>
|
||||
<.link navigate={~p"/#{@reply.user.username}"} class="text-gray-500 transition-colors hover:underline">
|
||||
@<%= @reply.user.username %>
|
||||
</.link>
|
||||
<span class="text-gray-500">·</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
<%= Timex.format!(@reply.inserted_at, "{relative}", :relative) %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :if={@parent_post} class="text-sm text-gray-500 mt-1">
|
||||
Replying to <.link navigate={~p"/#{@parent_post.user.username}"} class="text-blue-500 hover:underline">@<%= @parent_post.user.username %></.link>
|
||||
</div>
|
||||
|
||||
<p :if={@reply.body && String.trim(@reply.body) != "" && String.trim(@reply.body) != " "} class="mt-2 whitespace-pre-wrap">
|
||||
<%= @reply.body %>
|
||||
</p>
|
||||
|
||||
<!-- Reply Actions -->
|
||||
<div class="flex items-center gap-6 mt-3">
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="open_reply_modal"
|
||||
phx-value-id={@reply.id}
|
||||
class="gap-2 text-gray-500 hover:text-blue-500"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @reply.replies_count %></span>
|
||||
</.ui_button>
|
||||
|
||||
<.ui_button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click={if liked_by_user?(@reply, @current_user), do: "unlike_post", else: "like_post"}
|
||||
phx-value-id={@reply.id}
|
||||
class={[
|
||||
"gap-2",
|
||||
liked_by_user?(@reply, @current_user) && "text-red-500 hover:text-red-600",
|
||||
!liked_by_user?(@reply, @current_user) && "text-gray-500 hover:text-red-500"
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
class={["w-4 h-4", liked_by_user?(@reply, @current_user) && "fill-current"]}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @reply.likes_count %></span>
|
||||
</.ui_button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions that need to be available
|
||||
defp reposted_by_user?(post, user) do
|
||||
Social.reposted_by_user?(user.id, post.id)
|
||||
end
|
||||
|
||||
defp liked_by_user?(post, user) do
|
||||
Social.liked_by_user?(user.id, post.id)
|
||||
end
|
||||
end
|
||||
368
lib/malarkey_web/components/ui.ex
Normal file
@@ -0,0 +1,368 @@
|
||||
defmodule MalarkeyWeb.Components.UI do
|
||||
@moduledoc """
|
||||
Shadcn-inspired UI components for Malarkey.
|
||||
|
||||
These components follow the shadcn/ui design system adapted for Phoenix LiveView.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a button with shadcn styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.ui_button>Click me</.ui_button>
|
||||
<.ui_button variant="outline">Outlined</.ui_button>
|
||||
<.ui_button variant="ghost">Ghost</.ui_button>
|
||||
<.ui_button size="lg">Large button</.ui_button>
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default outline ghost destructive)
|
||||
attr :size, :string, default: "default", values: ~w(default sm lg icon)
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global, include: ~w(disabled type phx-click phx-disable-with phx-value-id phx-value-content)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
class={[
|
||||
# Base styles
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
# Variant styles
|
||||
variant_classes(@variant),
|
||||
# Size styles
|
||||
size_classes(@size),
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp variant_classes("default"),
|
||||
do: "bg-primary text-primary-foreground shadow hover:bg-primary/90"
|
||||
|
||||
defp variant_classes("outline"),
|
||||
do:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
defp variant_classes("ghost"), do: "hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
defp variant_classes("destructive"),
|
||||
do: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90"
|
||||
|
||||
defp size_classes("default"), do: "h-9 px-4 py-2"
|
||||
defp size_classes("sm"), do: "h-8 rounded-md px-3 text-xs"
|
||||
defp size_classes("lg"), do: "h-10 rounded-md px-8"
|
||||
defp size_classes("icon"), do: "h-9 w-9"
|
||||
|
||||
@doc """
|
||||
Renders a shadcn-styled input field.
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
attr :type, :string, default: "text"
|
||||
attr :class, :string, default: nil
|
||||
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
slot :inner_block
|
||||
|
||||
def ui_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> ui_input()
|
||||
end
|
||||
|
||||
def ui_input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={[
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<label
|
||||
:if={@label}
|
||||
for={@id}
|
||||
class="text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def ui_input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label :if={@label} for={@id} class="block text-sm font-medium leading-6 text-foreground mb-2">
|
||||
<%= @label %>
|
||||
</label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.ui_input_error :for={msg <- @errors}><%= msg %></.ui_input_error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp ui_input_error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-1 flex gap-1 text-sm leading-6 text-destructive">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5 flex-none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a shadcn-styled card.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={[
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card header.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_header(assigns) do
|
||||
~H"""
|
||||
<div class={["flex flex-col space-y-1.5 p-6", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card title.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_title(assigns) do
|
||||
~H"""
|
||||
<h3 class={["font-semibold leading-none tracking-tight", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h3>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card description.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_description(assigns) do
|
||||
~H"""
|
||||
<p class={["text-sm text-muted-foreground", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card content area.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_card_content(assigns) do
|
||||
~H"""
|
||||
<div class={["p-6 pt-0", @class]}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a separator.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
|
||||
|
||||
def ui_separator(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"shrink-0 bg-border",
|
||||
@orientation == "horizontal" && "h-[1px] w-full",
|
||||
@orientation == "vertical" && "h-full w-[1px]",
|
||||
@class
|
||||
]}
|
||||
role="separator"
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an alert with shadcn styling.
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default destructive)
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_alert(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm",
|
||||
@variant == "default" && "bg-background text-foreground",
|
||||
@variant == "destructive" &&
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
@class
|
||||
]}
|
||||
role="alert"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a badge with shadcn styling.
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default secondary outline destructive)
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def ui_badge(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
badge_variant_classes(@variant),
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp badge_variant_classes("default"),
|
||||
do: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80"
|
||||
|
||||
defp badge_variant_classes("secondary"),
|
||||
do: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
|
||||
defp badge_variant_classes("outline"), do: "text-foreground"
|
||||
|
||||
defp badge_variant_classes("destructive"),
|
||||
do: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80"
|
||||
|
||||
# Helper function to translate errors
|
||||
defp translate_error({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end
|
||||
end
|
||||
27
lib/malarkey_web/controllers/auth_controller.ex
Normal file
@@ -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
|
||||
24
lib/malarkey_web/controllers/error_html.ex
Normal 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
|
||||
21
lib/malarkey_web/controllers/error_json.ex
Normal 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
|
||||
9
lib/malarkey_web/controllers/page_controller.ex
Normal 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
|
||||
10
lib/malarkey_web/controllers/page_html.ex
Normal 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
|
||||
222
lib/malarkey_web/controllers/page_html/home.html.heex
Normal 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 & 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>
|
||||
33
lib/malarkey_web/controllers/user_confirmation_controller.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationController do
|
||||
use MalarkeyWeb, :controller
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def edit(conn, %{"token" => token}) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "User confirmed successfully.")
|
||||
|> redirect(to: ~p"/")
|
||||
|
||||
:error ->
|
||||
# If there is a current user and the account was already confirmed,
|
||||
# then odds are that the confirmation link was already visited, either
|
||||
# by some automation or by the user themselves, so we redirect without
|
||||
# a warning message.
|
||||
case conn.assigns do
|
||||
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
|
||||
redirect(conn, to: ~p"/")
|
||||
|
||||
%{} ->
|
||||
conn
|
||||
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"token" => token}) do
|
||||
edit(conn, %{"token" => token})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
defmodule MalarkeyWeb.UserConfirmationInstructionsController do
|
||||
use MalarkeyWeb, :controller
|
||||
|
||||
alias Malarkey.Accounts
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, :new, page_title: "Resend confirmation instructions")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&url(~p"/users/confirm/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system and it has not been confirmed yet, " <>
|
||||
"you will receive an email with instructions shortly."
|
||||
)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
26
lib/malarkey_web/controllers/user_session_controller.ex
Normal 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
|
||||
57
lib/malarkey_web/endpoint.ex
Normal 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
|
||||
25
lib/malarkey_web/gettext.ex
Normal 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
|
||||
61
lib/malarkey_web/live/explore_live/index.ex
Normal file
@@ -0,0 +1,61 @@
|
||||
defmodule MalarkeyWeb.ExploreLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Explore")
|
||||
|> assign(:search_query, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
{:noreply, assign(socket, :search_query, query)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h1 class="text-xl font-bold">Explore</h1>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Malarkey"
|
||||
value={@search_query}
|
||||
phx-change="search"
|
||||
class="w-full px-4 py-3 rounded-full bg-gray-100 dark:bg-gray-800 border-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p>Try searching for people, topics, or keywords</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
49
lib/malarkey_web/live/notification_live/index.ex
Normal file
@@ -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
|
||||
343
lib/malarkey_web/live/post_live/show.ex
Normal 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
|
||||
124
lib/malarkey_web/live/profile_live/followers.ex
Normal file
@@ -0,0 +1,124 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Followers do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
followers = Social.list_followers(user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username} - Followers")
|
||||
|> stream(:followers, followers)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", %{"id" => id}, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", %{"id" => id}, socket) do
|
||||
case Social.delete_follow(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<.link class="text-blue-500 hover:underline">
|
||||
← Back
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold mt-2">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="font-bold text-lg">Followers</h2>
|
||||
</div>
|
||||
|
||||
<div id="followers" phx-update="stream">
|
||||
<div
|
||||
:for={{dom_id, follower} <- @streams.followers}
|
||||
id={dom_id}
|
||||
class="border-b border-gray-200 dark:border-gray-700 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex space-x-3 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={follower} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold">
|
||||
<%= follower.display_name || follower.username %>
|
||||
</div>
|
||||
<div class="text-gray-500">@<%= follower.username %></div>
|
||||
<%= if follower.bio do %>
|
||||
<p class="text-sm mt-1 line-clamp-2"><%= follower.bio %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @current_user.id != follower.id do %>
|
||||
<%= if following?(@current_user, follower) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
phx-value-id={follower.id}
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
phx-value-id={follower.id}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp following?(current_user, other_user) do
|
||||
Social.following?(current_user.id, other_user.id)
|
||||
end
|
||||
end
|
||||
124
lib/malarkey_web/live/profile_live/following.ex
Normal file
@@ -0,0 +1,124 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Following do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
following = Social.list_following(user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username} - Following")
|
||||
|> stream(:following, following)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", %{"id" => id}, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", %{"id" => id}, socket) do
|
||||
case Social.delete_follow(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<.link class="text-blue-500 hover:underline">
|
||||
← Back
|
||||
</.link>
|
||||
<h1 class="text-xl font-bold mt-2">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="font-bold text-lg">Following</h2>
|
||||
</div>
|
||||
|
||||
<div id="following" phx-update="stream">
|
||||
<div
|
||||
:for={{dom_id, followed_user} <- @streams.following}
|
||||
id={dom_id}
|
||||
class="border-b border-gray-200 dark:border-gray-700 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex space-x-3 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<.avatar user={followed_user} size="lg" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold">
|
||||
<%= followed_user.display_name || followed_user.username %>
|
||||
</div>
|
||||
<div class="text-gray-500">@<%= followed_user.username %></div>
|
||||
<%= if followed_user.bio do %>
|
||||
<p class="text-sm mt-1 line-clamp-2"><%= followed_user.bio %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @current_user.id != followed_user.id do %>
|
||||
<%= if following?(@current_user, followed_user) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
phx-value-id={followed_user.id}
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
phx-value-id={followed_user.id}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp following?(current_user, other_user) do
|
||||
Social.following?(current_user.id, other_user.id)
|
||||
end
|
||||
end
|
||||
329
lib/malarkey_web/live/profile_live/index.ex
Normal file
@@ -0,0 +1,329 @@
|
||||
defmodule MalarkeyWeb.ProfileLive.Index do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
import MalarkeyWeb.Components.UI
|
||||
import MalarkeyWeb.Components.Posts
|
||||
|
||||
alias Malarkey.Accounts
|
||||
alias Malarkey.Social
|
||||
|
||||
@impl true
|
||||
def mount(%{"username" => username}, _session, socket) do
|
||||
user =
|
||||
case Accounts.get_user_by_username(username) do
|
||||
nil -> raise Ecto.NoResultsError, queryable: Malarkey.Accounts.User
|
||||
user -> user
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:profile_user, user)
|
||||
|> assign(:page_title, "@#{username}")
|
||||
|> assign(:active_tab, :posts)
|
||||
|> load_posts()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :posts)
|
||||
|> load_posts()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :replies, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :replies)
|
||||
|> load_replies()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :media, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :media)
|
||||
|> load_media()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :likes, _params) do
|
||||
socket
|
||||
|> assign(:active_tab, :likes)
|
||||
|> load_likes()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("follow", _params, socket) do
|
||||
case Social.create_follow(%{
|
||||
follower_id: socket.assigns.current_user.id,
|
||||
following_id: socket.assigns.profile_user.id
|
||||
}) do
|
||||
{:ok, _follow} ->
|
||||
updated_user = Accounts.get_user!(socket.assigns.profile_user.id)
|
||||
{:noreply, assign(socket, :profile_user, updated_user)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to follow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unfollow", _params, socket) do
|
||||
case Social.delete_follow(
|
||||
socket.assigns.current_user.id,
|
||||
socket.assigns.profile_user.id
|
||||
) do
|
||||
{:ok, _} ->
|
||||
updated_user = Accounts.get_user!(socket.assigns.profile_user.id)
|
||||
{:noreply, assign(socket, :profile_user, updated_user)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unfollow user")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("like_post", %{"id" => id}, socket) do
|
||||
case Social.create_like(%{user_id: socket.assigns.current_user.id, post_id: id}) do
|
||||
{:ok, _like} ->
|
||||
{:noreply, reload_current_tab(socket)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to like post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlike_post", %{"id" => id}, socket) do
|
||||
case Social.delete_like(socket.assigns.current_user.id, id) do
|
||||
{:ok, _} ->
|
||||
{:noreply, reload_current_tab(socket)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlike post")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Profile Header -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-800">
|
||||
<!-- Header Section -->
|
||||
<%= if @profile_user.header_url do %>
|
||||
<div class="relative h-48 bg-gradient-to-r from-blue-400 to-purple-500">
|
||||
<img
|
||||
src={@profile_user.header_url}
|
||||
alt="Header"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="h-48 bg-gradient-to-r from-blue-400 to-purple-500"></div>
|
||||
<% end %>
|
||||
|
||||
<!-- Avatar Section (overlapping header) -->
|
||||
<div class="flex justify-between items-start px-4 -mt-16 relative z-10">
|
||||
<div class="flex-shrink-0">
|
||||
<%= if @profile_user.avatar_url do %>
|
||||
<img
|
||||
src={@profile_user.avatar_url}
|
||||
alt={@profile_user.display_name || @profile_user.username}
|
||||
class="w-32 h-32 rounded-full border-4 border-white dark:border-gray-900 object-cover"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="w-32 h-32 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold border-4 border-white dark:border-gray-900">
|
||||
<%= String.first(@profile_user.display_name || @profile_user.username) |> String.upcase() %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<%= if @current_user.id == @profile_user.id do %>
|
||||
<.link
|
||||
navigate={~p"/settings/profile"}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Edit Profile
|
||||
</.link>
|
||||
<% else %>
|
||||
<%= if following?(@current_user, @profile_user) do %>
|
||||
<button
|
||||
phx-click="unfollow"
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="follow"
|
||||
class="px-4 py-2 bg-black text-white dark:bg-white dark:text-black rounded-full font-bold hover:opacity-80"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 mt-4">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold">
|
||||
<%= @profile_user.display_name || @profile_user.username %>
|
||||
</h1>
|
||||
<p class="text-gray-500">@<%= @profile_user.username %></p>
|
||||
</div>
|
||||
|
||||
<%= if @profile_user.bio do %>
|
||||
<p class="whitespace-pre-wrap"><%= @profile_user.bio %></p>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-gray-500">
|
||||
<%= if @profile_user.location do %>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @profile_user.location %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @profile_user.website do %>
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<a href={@profile_user.website} target="_blank" class="text-blue-500 hover:underline">
|
||||
<%= URI.parse(@profile_user.website).host || @profile_user.website %>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Joined <%= Timex.format!(@profile_user.inserted_at, "{Mshort} {YYYY}") %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<div>
|
||||
<span class="font-bold"><%= @profile_user.following_count %></span>
|
||||
<span class="text-gray-500"> Following</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold"><%= @profile_user.followers_count %></span>
|
||||
<span class="text-gray-500"> Followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :posts && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Posts
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :replies && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Replies
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :media && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Media
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"flex-1 py-4 font-bold hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
@active_tab == :likes && "border-b-4 border-blue-500"
|
||||
]}
|
||||
>
|
||||
Likes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post List -->
|
||||
<div id="posts" phx-update="stream" class="space-y-4">
|
||||
<.post_card
|
||||
:for={{dom_id, post} <- @streams.posts}
|
||||
dom_id={dom_id}
|
||||
post={post}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp load_posts(socket) do
|
||||
posts = Social.list_user_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_replies(socket) do
|
||||
# For now, just show all posts including replies
|
||||
posts = Social.list_user_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_media(socket) do
|
||||
posts = Social.list_user_media_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp load_likes(socket) do
|
||||
posts = Social.list_user_liked_posts(socket.assigns.profile_user)
|
||||
stream(socket, :posts, posts, reset: true)
|
||||
end
|
||||
|
||||
defp reload_current_tab(socket) do
|
||||
case socket.assigns.active_tab do
|
||||
:posts -> load_posts(socket)
|
||||
:replies -> load_replies(socket)
|
||||
:media -> load_media(socket)
|
||||
:likes -> load_likes(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp following?(current_user, profile_user) do
|
||||
Social.following?(current_user.id, profile_user.id)
|
||||
end
|
||||
end
|
||||
333
lib/malarkey_web/live/profile_settings_live.ex
Normal file
@@ -0,0 +1,333 @@
|
||||
defmodule MalarkeyWeb.ProfileSettingsLive do
|
||||
use MalarkeyWeb, :live_view
|
||||
|
||||
alias Malarkey.{Accounts, Media}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_user
|
||||
changeset = Accounts.User.profile_changeset(user, %{})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Edit Profile")
|
||||
|> assign(:changeset, changeset)
|
||||
|> assign(:avatar_preview, user.avatar_url)
|
||||
|> assign(:header_preview, user.header_url)
|
||||
|> allow_upload(:avatar,
|
||||
accept: ~w(.jpg .jpeg .png .gif),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000
|
||||
)
|
||||
|> allow_upload(:header,
|
||||
accept: ~w(.jpg .jpeg .png .gif),
|
||||
max_entries: 1,
|
||||
max_file_size: 5_000_000
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.User.profile_changeset(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_avatar", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :avatar, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_header", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :header, ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_avatar", _params, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, %{avatar_url: nil}) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:avatar_preview, nil)
|
||||
|> put_flash(:info, "Avatar removed successfully")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove avatar")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("remove_header", _params, socket) do
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, %{header_url: nil}) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:header_preview, nil)
|
||||
|> put_flash(:info, "Header image removed successfully")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to remove header image")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
# Handle avatar upload
|
||||
avatar_url =
|
||||
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
|
||||
case Media.upload_file(path, entry.client_type, socket.assigns.current_user.id) do
|
||||
{:ok, url} -> {:ok, url}
|
||||
{:error, _} -> {:postpone, :error}
|
||||
end
|
||||
end)
|
||||
|> List.first()
|
||||
|
||||
# Handle header upload
|
||||
header_url =
|
||||
consume_uploaded_entries(socket, :header, fn %{path: path}, entry ->
|
||||
case Media.upload_file(path, entry.client_type, socket.assigns.current_user.id) do
|
||||
{:ok, url} -> {:ok, url}
|
||||
{:error, _} -> {:postpone, :error}
|
||||
end
|
||||
end)
|
||||
|> List.first()
|
||||
|
||||
# Merge uploaded URLs with form params
|
||||
user_params =
|
||||
user_params
|
||||
|> maybe_put_avatar(avatar_url)
|
||||
|> maybe_put_header(header_url)
|
||||
|
||||
case Accounts.update_user_profile(socket.assigns.current_user, user_params) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:avatar_preview, user.avatar_url)
|
||||
|> assign(:header_preview, user.header_url)
|
||||
|> put_flash(:info, "Profile updated successfully")
|
||||
|> push_navigate(to: ~p"/#{user.username}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_avatar(params, nil), do: params
|
||||
defp maybe_put_avatar(params, url), do: Map.put(params, "avatar_url", url)
|
||||
|
||||
defp maybe_put_header(params, nil), do: params
|
||||
defp maybe_put_header(params, url), do: Map.put(params, "header_url", url)
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<.link navigate={~p"/#{@current_user.username}"} class="text-blue-500 hover:underline">
|
||||
← Back to profile
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-2xl font-bold">Edit Profile</h1>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="profile-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
class="space-y-8"
|
||||
>
|
||||
<!-- Avatar Image -->
|
||||
<div class="pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block mb-4 text-sm font-medium">Profile Picture</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<%= if @avatar_preview do %>
|
||||
<img
|
||||
src={@avatar_preview}
|
||||
alt="Avatar"
|
||||
class="object-cover w-32 h-32 border-4 border-white rounded-full dark:border-gray-800"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center w-32 h-32 text-4xl font-bold text-gray-600 bg-gray-300 border-4 border-white rounded-full dark:bg-gray-600 dark:text-gray-300 dark:border-gray-800">
|
||||
<%= String.first(@current_user.username) |> String.upcase() %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<label
|
||||
for={@uploads.avatar.ref}
|
||||
class="px-4 py-2 text-sm font-semibold text-white transition-colors bg-blue-500 rounded-full cursor-pointer hover:bg-blue-600"
|
||||
>
|
||||
<svg class="inline w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Upload Photo
|
||||
</label>
|
||||
<%= if @avatar_preview do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_avatar"
|
||||
class="px-4 py-2 text-sm font-semibold text-white transition-colors bg-red-500 rounded-full hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.live_file_input upload={@uploads.avatar} class="hidden" />
|
||||
|
||||
<%= for entry <- @uploads.avatar.entries do %>
|
||||
<div class="flex items-center justify-between p-2 mt-2 bg-gray-100 rounded dark:bg-gray-700">
|
||||
<span class="text-sm"><%= entry.client_name %></span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_avatar"
|
||||
phx-value-ref={entry.ref}
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Header Image -->
|
||||
<div class="pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<label class="block mb-4 text-sm font-medium">Header Image</label>
|
||||
<div class="relative">
|
||||
<div class="h-48 overflow-hidden bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg">
|
||||
<%= if @header_preview do %>
|
||||
<img src={@header_preview} alt="Header" class="object-cover w-full h-full" />
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="absolute flex gap-2 top-2 right-2">
|
||||
<label
|
||||
for={@uploads.header.ref}
|
||||
class="px-3 py-2 text-sm font-semibold text-white transition-colors bg-black rounded-full cursor-pointer bg-opacity-60 hover:bg-opacity-80"
|
||||
>
|
||||
<svg class="inline w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Change
|
||||
</label>
|
||||
<%= if @header_preview do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_header"
|
||||
class="px-3 py-2 text-sm font-semibold text-white transition-colors bg-red-600 rounded-full bg-opacity-60 hover:bg-opacity-80"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.live_file_input upload={@uploads.header} class="hidden" />
|
||||
|
||||
<%= for entry <- @uploads.header.entries do %>
|
||||
<div class="flex items-center justify-between p-2 mt-2 bg-gray-100 rounded dark:bg-gray-700">
|
||||
<span class="text-sm"><%= entry.client_name %></span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_header"
|
||||
phx-value-ref={entry.ref}
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:display_name]}
|
||||
type="text"
|
||||
label="Display Name"
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:bio]}
|
||||
type="textarea"
|
||||
label="Bio"
|
||||
placeholder="Tell us about yourself"
|
||||
rows="3"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= String.length(Phoenix.HTML.Form.input_value(f, :bio) || "") %>/160
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<.input field={f[:location]} type="text" label="Location" placeholder="Where are you?" />
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div>
|
||||
<.input
|
||||
field={f[:website]}
|
||||
type="text"
|
||||
label="Website"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-6 py-2 font-semibold text-white transition-colors bg-blue-500 rounded-full hover:bg-blue-600"
|
||||
phx-disable-with="Saving..."
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<.link
|
||||
navigate={~p"/#{@current_user.username}"}
|
||||
class="flex items-center justify-center flex-1 px-6 py-2 font-semibold text-gray-700 transition-colors border border-gray-300 rounded-full hover:bg-gray-100 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
600
lib/malarkey_web/live/timeline_live/index.ex
Normal file
@@ -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
|
||||
59
lib/malarkey_web/live/user_forgot_password_live.ex
Normal 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
|
||||
74
lib/malarkey_web/live/user_login_live.ex
Normal 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
|
||||
121
lib/malarkey_web/live/user_registration_live.ex
Normal 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
|
||||
103
lib/malarkey_web/live/user_reset_password_live.ex
Normal 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
|
||||
301
lib/malarkey_web/live/user_settings_live.ex
Normal 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
@@ -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
|
||||
92
lib/malarkey_web/telemetry.ex
Normal 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
|
||||
228
lib/malarkey_web/user_auth.ex
Normal 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
@@ -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
@@ -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"},
|
||||
}
|
||||
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal 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
@@ -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 ""
|
||||
4
priv/repo/migrations/.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
import_deps: [:ecto_sql],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
28
priv/repo/migrations/20251029000001_create_users.exs
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateUsers do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:users, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :string
|
||||
add :username, :string, null: false
|
||||
add :display_name, :string
|
||||
add :bio, :text
|
||||
add :location, :string
|
||||
add :website, :string
|
||||
add :avatar_url, :string
|
||||
add :header_url, :string
|
||||
add :hashed_password, :string
|
||||
add :verified, :boolean, default: false
|
||||
add :followers_count, :integer, default: 0
|
||||
add :following_count, :integer, default: 0
|
||||
add :posts_count, :integer, default: 0
|
||||
add :confirmed_at, :naive_datetime
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
create unique_index(:users, [:username])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Users tokens for sessions, email confirmation, password reset
|
||||
create table(:users_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
|
||||
# OAuth identities table
|
||||
create table(:oauth_identities, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :provider, :string, null: false
|
||||
add :provider_uid, :string, null: false
|
||||
add :provider_email, :string
|
||||
add :provider_login, :string
|
||||
add :provider_token, :text
|
||||
add :provider_meta, :map
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:oauth_identities, [:user_id])
|
||||
create unique_index(:oauth_identities, [:provider, :provider_uid])
|
||||
end
|
||||
end
|
||||
27
priv/repo/migrations/20251029000003_create_posts.exs
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreatePosts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:posts, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :content, :text, null: false
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :reply_to_id, references(:posts, type: :binary_id, on_delete: :nilify_all)
|
||||
add :repost_of_id, references(:posts, type: :binary_id, on_delete: :delete_all)
|
||||
add :quote_post_id, references(:posts, type: :binary_id, on_delete: :nilify_all)
|
||||
add :media_urls, {:array, :string}, default: []
|
||||
add :likes_count, :integer, default: 0
|
||||
add :reposts_count, :integer, default: 0
|
||||
add :replies_count, :integer, default: 0
|
||||
add :views_count, :integer, default: 0
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:posts, [:user_id])
|
||||
create index(:posts, [:reply_to_id])
|
||||
create index(:posts, [:repost_of_id])
|
||||
create index(:posts, [:quote_post_id])
|
||||
create index(:posts, [:inserted_at])
|
||||
end
|
||||
end
|
||||
17
priv/repo/migrations/20251029000004_create_likes.exs
Normal file
@@ -0,0 +1,17 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateLikes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:likes, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :post_id, references(:posts, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:likes, [:user_id])
|
||||
create index(:likes, [:post_id])
|
||||
create unique_index(:likes, [:user_id, :post_id])
|
||||
end
|
||||
end
|
||||
20
priv/repo/migrations/20251029000005_create_follows.exs
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule Malarkey.Repo.Migrations.CreateFollows do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:follows, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :follower_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :following_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
create index(:follows, [:follower_id])
|
||||
create index(:follows, [:following_id])
|
||||
create unique_index(:follows, [:follower_id, :following_id])
|
||||
|
||||
# Ensure users can't follow themselves
|
||||
create constraint(:follows, :cannot_follow_self, check: "follower_id != following_id")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Malarkey.Repo.Migrations.AddMediaTypesToPosts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:posts) do
|
||||
add :media_types, {:array, :string}, default: []
|
||||
end
|
||||
end
|
||||
end
|
||||
11
priv/repo/seeds.exs
Normal 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
|
After Width: | Height: | Size: 152 B |
6
priv/static/images/logo.svg
Normal 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
@@ -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: /
|
||||
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 289 KiB |