Compare commits
6 Commits
0f41e86cf0
...
a926733f1b
| Author | SHA1 | Date | |
|---|---|---|---|
| a926733f1b | |||
| a70ea18e56 | |||
| abe10922eb | |||
| 9c131b98a6 | |||
| f82bc223bb | |||
| 580265bc51 |
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Phoenix / App
|
||||||
|
PHX_HOST=mixer.example.com
|
||||||
|
PHX_SERVER=true
|
||||||
|
PORT=4000
|
||||||
|
SECRET_KEY_BASE=REPLACE_WITH_64_CHAR_SECRET # generate with: mix phx.gen.secret
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=ecto://USER:PASSWORD@HOST/DATABASE
|
||||||
|
ECTO_IPV6=false
|
||||||
|
POOL_SIZE=10
|
||||||
|
|
||||||
|
# Clustering (leave blank if not using DNS-based clustering)
|
||||||
|
DNS_CLUSTER_QUERY=
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
TOKEN_SIGNING_SECRET=REPLACE_WITH_SECRET
|
||||||
|
|
||||||
|
# S3 / Object Storage
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
S3_HOST=s3.amazonaws.com
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_ASSET_HOST=https://your-bucket.s3.amazonaws.com
|
||||||
|
S3_SCHEME=https://
|
||||||
|
S3_PORT=80
|
||||||
|
S3_VIRTUAL_HOST=false
|
||||||
|
|
||||||
|
# Email (Brevo)
|
||||||
|
BREVO_API_KEY=your-brevo-api-key
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# .env
|
||||||
|
.env
|
||||||
|
|
||||||
# The directory Mix will write compiled artifacts to.
|
# The directory Mix will write compiled artifacts to.
|
||||||
/_build/
|
/_build/
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,30 @@ html, body {
|
|||||||
.mx-action-delete:hover { color: var(--mx-red); background: color-mix(in oklch, var(--mx-red) 10%, transparent); }
|
.mx-action-delete:hover { color: var(--mx-red); background: color-mix(in oklch, var(--mx-red) 10%, transparent); }
|
||||||
.mx-action-confirm { color: var(--mx-red) !important; background: color-mix(in oklch, var(--mx-red) 15%, transparent) !important; }
|
.mx-action-confirm { color: var(--mx-red) !important; background: color-mix(in oklch, var(--mx-red) 15%, transparent) !important; }
|
||||||
|
|
||||||
|
.mx-follow-btn {
|
||||||
|
padding: 0.25rem 0.875rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1.5px solid var(--mx-border2);
|
||||||
|
background: none;
|
||||||
|
color: var(--mx-fg);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.mx-follow-btn:hover:not(:disabled) { background: var(--mx-surface2); }
|
||||||
|
.mx-follow-btn--following {
|
||||||
|
background: var(--mx-surface);
|
||||||
|
color: var(--mx-muted);
|
||||||
|
}
|
||||||
|
.mx-follow-btn--following:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
|
||||||
|
border-color: var(--mx-red);
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.mx-follow-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
.mx-tweet-text {
|
.mx-tweet-text {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -734,3 +758,47 @@ html, body {
|
|||||||
border-radius: var(--mx-radius-sm);
|
border-radius: var(--mx-radius-sm);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Context Menu ── */
|
||||||
|
.mx-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 300;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--mx-surface2);
|
||||||
|
border: 1px solid var(--mx-border2);
|
||||||
|
border-radius: var(--mx-radius-sm);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 0;
|
||||||
|
animation: mx-ctx-in 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mx-ctx-in {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.875rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--mx-fg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-context-menu-item:hover {
|
||||||
|
background: color-mix(in oklch, var(--mx-accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-context-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--mx-border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Do not edit this file manually
|
// Do not edit this file manually
|
||||||
|
|
||||||
|
|
||||||
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types";
|
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, followsFilterInput, followsResourceSchema, followsSortField, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types";
|
||||||
export type * from "./ash_types";
|
export type * from "./ash_types";
|
||||||
|
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
@@ -201,6 +201,346 @@ export async function executeValidationRpcRequest<T>(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type FollowUserInput = {
|
||||||
|
followingId: UUID;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FollowUserFields = UnifiedFieldSelection<followsResourceSchema>[];
|
||||||
|
|
||||||
|
export type InferFollowUserResult<
|
||||||
|
Fields extends FollowUserFields | undefined,
|
||||||
|
> = InferResult<followsResourceSchema, Fields>;
|
||||||
|
|
||||||
|
export type FollowUserResult<Fields extends FollowUserFields | undefined = undefined> = | { success: true; data: InferFollowUserResult<Fields>; }
|
||||||
|
| { success: false; errors: AshRpcError[]; }
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Follow
|
||||||
|
*
|
||||||
|
* @ashActionType :create
|
||||||
|
*/
|
||||||
|
export async function followUser<Fields extends FollowUserFields | undefined = undefined>(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
input: FollowUserInput;
|
||||||
|
fields?: Fields;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<FollowUserResult<Fields extends undefined ? [] : Fields>> {
|
||||||
|
const payload = {
|
||||||
|
action: "follow_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
input: config.input,
|
||||||
|
...(config.fields !== undefined && { fields: config.fields })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeActionRpcRequest<FollowUserResult<Fields extends undefined ? [] : Fields>>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate: Create a new Follow
|
||||||
|
*
|
||||||
|
* @ashActionType :create
|
||||||
|
* @validation true
|
||||||
|
*/
|
||||||
|
export async function validateFollowUser(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
input: FollowUserInput;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "follow_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
input: config.input
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeValidationRpcRequest<ValidationResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type ReadFollowFields = UnifiedFieldSelection<followsResourceSchema>[];
|
||||||
|
|
||||||
|
|
||||||
|
export type InferReadFollowResult<
|
||||||
|
Fields extends ReadFollowFields | undefined,
|
||||||
|
Page extends ReadFollowConfig["page"] = undefined
|
||||||
|
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<followsResourceSchema, Fields>>, {
|
||||||
|
results: Array<InferResult<followsResourceSchema, Fields>>;
|
||||||
|
hasMore: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
count?: number | null;
|
||||||
|
type: "offset";
|
||||||
|
}, {
|
||||||
|
results: Array<InferResult<followsResourceSchema, Fields>>;
|
||||||
|
hasMore: boolean;
|
||||||
|
limit: number;
|
||||||
|
after: string | null;
|
||||||
|
before: string | null;
|
||||||
|
previousPage: string;
|
||||||
|
nextPage: string;
|
||||||
|
count?: number | null;
|
||||||
|
type: "keyset";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ReadFollowConfig = {
|
||||||
|
tenant?: string;
|
||||||
|
fields: ReadFollowFields;
|
||||||
|
filter?: followsFilterInput;
|
||||||
|
sort?: SortString<followsSortField> | SortString<followsSortField>[];
|
||||||
|
page?: (
|
||||||
|
{
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
count?: boolean;
|
||||||
|
} | {
|
||||||
|
limit?: number;
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadFollowResult<Fields extends ReadFollowFields, Page extends ReadFollowConfig["page"] = undefined> = | { success: true; data: InferReadFollowResult<Fields, Page>; }
|
||||||
|
| { success: false; errors: AshRpcError[]; }
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Follow records
|
||||||
|
*
|
||||||
|
* @ashActionType :read
|
||||||
|
*/
|
||||||
|
export async function readFollow<Fields extends ReadFollowFields, Config extends ReadFollowConfig = ReadFollowConfig>(
|
||||||
|
config: Config & { fields: Fields }
|
||||||
|
): Promise<ReadFollowResult<Fields, Config["page"]>> {
|
||||||
|
const payload = {
|
||||||
|
action: "read_follow",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
...(config.fields !== undefined && { fields: config.fields }),
|
||||||
|
...(config.filter && { filter: config.filter }),
|
||||||
|
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
|
||||||
|
...(config.page && { page: config.page })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeActionRpcRequest<ReadFollowResult<Fields, Config["page"]>>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate: Read Follow records
|
||||||
|
*
|
||||||
|
* @ashActionType :read
|
||||||
|
* @validation true
|
||||||
|
*/
|
||||||
|
export async function validateReadFollow(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "read_follow",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeValidationRpcRequest<ValidationResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type UnfollowUserInput = {
|
||||||
|
followingId: UUID;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InferUnfollowUserResult = {};
|
||||||
|
|
||||||
|
export type UnfollowUserResult = | { success: true; data: InferUnfollowUserResult; }
|
||||||
|
| { success: false; errors: AshRpcError[]; }
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute generic action on Follow
|
||||||
|
*
|
||||||
|
* @ashActionType :action
|
||||||
|
*/
|
||||||
|
export async function unfollowUser(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
input: UnfollowUserInput;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<UnfollowUserResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "unfollow_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
input: config.input
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeActionRpcRequest<UnfollowUserResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate: Execute generic action on Follow
|
||||||
|
*
|
||||||
|
* @ashActionType :action
|
||||||
|
* @validation true
|
||||||
|
*/
|
||||||
|
export async function validateUnfollowUser(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
input: UnfollowUserInput;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "unfollow_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
input: config.input
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeValidationRpcRequest<ValidationResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type ReadUserFields = UnifiedFieldSelection<usersResourceSchema>[];
|
||||||
|
|
||||||
|
|
||||||
|
export type InferReadUserResult<
|
||||||
|
Fields extends ReadUserFields | undefined,
|
||||||
|
Page extends ReadUserConfig["page"] = undefined
|
||||||
|
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<usersResourceSchema, Fields>>, {
|
||||||
|
results: Array<InferResult<usersResourceSchema, Fields>>;
|
||||||
|
hasMore: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
count?: number | null;
|
||||||
|
type: "offset";
|
||||||
|
}, {
|
||||||
|
results: Array<InferResult<usersResourceSchema, Fields>>;
|
||||||
|
hasMore: boolean;
|
||||||
|
limit: number;
|
||||||
|
after: string | null;
|
||||||
|
before: string | null;
|
||||||
|
previousPage: string;
|
||||||
|
nextPage: string;
|
||||||
|
count?: number | null;
|
||||||
|
type: "keyset";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ReadUserConfig = {
|
||||||
|
tenant?: string;
|
||||||
|
fields: ReadUserFields;
|
||||||
|
filter?: usersFilterInput;
|
||||||
|
sort?: SortString<usersSortField> | SortString<usersSortField>[];
|
||||||
|
page?: (
|
||||||
|
{
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
count?: boolean;
|
||||||
|
} | {
|
||||||
|
limit?: number;
|
||||||
|
after?: string;
|
||||||
|
before?: string;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadUserResult<Fields extends ReadUserFields, Page extends ReadUserConfig["page"] = undefined> = | { success: true; data: InferReadUserResult<Fields, Page>; }
|
||||||
|
| { success: false; errors: AshRpcError[]; }
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read User records
|
||||||
|
*
|
||||||
|
* @ashActionType :read
|
||||||
|
*/
|
||||||
|
export async function readUser<Fields extends ReadUserFields, Config extends ReadUserConfig = ReadUserConfig>(
|
||||||
|
config: Config & { fields: Fields }
|
||||||
|
): Promise<ReadUserResult<Fields, Config["page"]>> {
|
||||||
|
const payload = {
|
||||||
|
action: "read_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||||
|
...(config.fields !== undefined && { fields: config.fields }),
|
||||||
|
...(config.filter && { filter: config.filter }),
|
||||||
|
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
|
||||||
|
...(config.page && { page: config.page })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeActionRpcRequest<ReadUserResult<Fields, Config["page"]>>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate: Read User records
|
||||||
|
*
|
||||||
|
* @ashActionType :read
|
||||||
|
* @validation true
|
||||||
|
*/
|
||||||
|
export async function validateReadUser(
|
||||||
|
config: {
|
||||||
|
tenant?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
|
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
}
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const payload = {
|
||||||
|
action: "read_user",
|
||||||
|
...(config.tenant !== undefined && { tenant: config.tenant })
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeValidationRpcRequest<ValidationResult>(
|
||||||
|
payload,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,44 @@
|
|||||||
export type UUID = string;
|
export type UUID = string;
|
||||||
export type UtcDateTimeUsec = string;
|
export type UtcDateTimeUsec = string;
|
||||||
|
|
||||||
|
// follows Schema
|
||||||
|
export type followsResourceSchema = {
|
||||||
|
__type: "Resource";
|
||||||
|
__primitiveFields: "id";
|
||||||
|
id: UUID;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type followsAttributesOnlySchema = {
|
||||||
|
__type: "Resource";
|
||||||
|
__primitiveFields: "id";
|
||||||
|
id: UUID;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// users Schema
|
||||||
|
export type usersResourceSchema = {
|
||||||
|
__type: "Resource";
|
||||||
|
__primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
|
||||||
|
id: UUID;
|
||||||
|
email: string;
|
||||||
|
followerCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
amIFollowing: boolean;
|
||||||
|
myFollowId: UUID;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type usersAttributesOnlySchema = {
|
||||||
|
__type: "Resource";
|
||||||
|
__primitiveFields: "id" | "email";
|
||||||
|
id: UUID;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// media Schema
|
// media Schema
|
||||||
export type mediaResourceSchema = {
|
export type mediaResourceSchema = {
|
||||||
__type: "Resource";
|
__type: "Resource";
|
||||||
@@ -14,6 +52,7 @@ export type mediaResourceSchema = {
|
|||||||
s3Key: string;
|
s3Key: string;
|
||||||
userId: UUID;
|
userId: UUID;
|
||||||
tweetId: UUID | null;
|
tweetId: UUID | null;
|
||||||
|
user: { __type: "Relationship"; __resource: usersResourceSchema; };
|
||||||
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +80,7 @@ export type tweetsResourceSchema = {
|
|||||||
state: "posted" | "drafted";
|
state: "posted" | "drafted";
|
||||||
likedByMe: boolean;
|
likedByMe: boolean;
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
|
user: { __type: "Relationship"; __resource: usersResourceSchema; };
|
||||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +98,74 @@ export type tweetsAttributesOnlySchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type followsFilterInput = {
|
||||||
|
and?: Array<followsFilterInput>;
|
||||||
|
or?: Array<followsFilterInput>;
|
||||||
|
not?: Array<followsFilterInput>;
|
||||||
|
|
||||||
|
id?: {
|
||||||
|
eq?: UUID;
|
||||||
|
notEq?: UUID;
|
||||||
|
in?: Array<UUID>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
export type usersFilterInput = {
|
||||||
|
and?: Array<usersFilterInput>;
|
||||||
|
or?: Array<usersFilterInput>;
|
||||||
|
not?: Array<usersFilterInput>;
|
||||||
|
|
||||||
|
id?: {
|
||||||
|
eq?: UUID;
|
||||||
|
notEq?: UUID;
|
||||||
|
in?: Array<UUID>;
|
||||||
|
};
|
||||||
|
|
||||||
|
email?: {
|
||||||
|
eq?: string;
|
||||||
|
notEq?: string;
|
||||||
|
in?: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
followerCount?: {
|
||||||
|
eq?: number;
|
||||||
|
notEq?: number;
|
||||||
|
greaterThan?: number;
|
||||||
|
greaterThanOrEqual?: number;
|
||||||
|
lessThan?: number;
|
||||||
|
lessThanOrEqual?: number;
|
||||||
|
in?: Array<number>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
followingCount?: {
|
||||||
|
eq?: number;
|
||||||
|
notEq?: number;
|
||||||
|
greaterThan?: number;
|
||||||
|
greaterThanOrEqual?: number;
|
||||||
|
lessThan?: number;
|
||||||
|
lessThanOrEqual?: number;
|
||||||
|
in?: Array<number>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
amIFollowing?: {
|
||||||
|
eq?: boolean;
|
||||||
|
notEq?: boolean;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
myFollowId?: {
|
||||||
|
eq?: UUID;
|
||||||
|
notEq?: UUID;
|
||||||
|
in?: Array<UUID>;
|
||||||
|
isNil?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
export type mediaFilterInput = {
|
export type mediaFilterInput = {
|
||||||
and?: Array<mediaFilterInput>;
|
and?: Array<mediaFilterInput>;
|
||||||
or?: Array<mediaFilterInput>;
|
or?: Array<mediaFilterInput>;
|
||||||
@@ -89,6 +197,8 @@ export type mediaFilterInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
user?: usersFilterInput;
|
||||||
|
|
||||||
tweet?: tweetsFilterInput;
|
tweet?: tweetsFilterInput;
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -154,11 +264,19 @@ export type tweetsFilterInput = {
|
|||||||
isNil?: boolean;
|
isNil?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
user?: usersFilterInput;
|
||||||
|
|
||||||
media?: mediaFilterInput;
|
media?: mediaFilterInput;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const followsFilterFields = ["id"] as const;
|
||||||
|
export type followsFilterField = (typeof followsFilterFields)[number];
|
||||||
|
|
||||||
|
export const usersFilterFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||||
|
export type usersFilterField = (typeof usersFilterFields)[number];
|
||||||
|
|
||||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||||
|
|
||||||
@@ -166,6 +284,12 @@ export const tweetsFilterFields = ["id", "content", "likes", "userId", "inserted
|
|||||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||||
|
|
||||||
|
|
||||||
|
export const followsSortFields = ["id"] as const;
|
||||||
|
export type followsSortField = (typeof followsSortFields)[number];
|
||||||
|
|
||||||
|
export const usersSortFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||||
|
export type usersSortField = (typeof usersSortFields)[number];
|
||||||
|
|
||||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
likeTweet,
|
likeTweet,
|
||||||
unlikeTweet,
|
unlikeTweet,
|
||||||
updateTweet,
|
updateTweet,
|
||||||
|
readUser,
|
||||||
|
followUser,
|
||||||
|
unfollowUser,
|
||||||
buildCSRFHeaders,
|
buildCSRFHeaders,
|
||||||
} from "./ash_rpc";
|
} from "./ash_rpc";
|
||||||
import { uploadFile } from "./upload";
|
import { uploadFile } from "./upload";
|
||||||
@@ -25,6 +28,14 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
followerCount?: number;
|
||||||
|
followingCount?: number;
|
||||||
|
amIFollowing?: boolean;
|
||||||
|
myFollowId?: string | null;
|
||||||
|
};
|
||||||
type MediaItem = { id: string; s3Key: string };
|
type MediaItem = { id: string; s3Key: string };
|
||||||
type Tweet = {
|
type Tweet = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +64,75 @@ function getAssetHost(): string {
|
|||||||
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Context menu ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ContextMenuItem =
|
||||||
|
| { type: "item"; label: string; onClick: () => void }
|
||||||
|
| { type: "separator" };
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const itemCount = items.filter((i) => i.type === "item").length;
|
||||||
|
const sepCount = items.filter((i) => i.type === "separator").length;
|
||||||
|
const menuH = itemCount * 34 + sepCount * 9 + 8;
|
||||||
|
const menuW = 180;
|
||||||
|
const left = Math.min(x, window.innerWidth - menuW - 8);
|
||||||
|
const top = Math.min(y, window.innerHeight - menuH - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="mx-context-menu"
|
||||||
|
style={{ left, top }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.type === "separator" ? (
|
||||||
|
<div key={i} className="mx-context-menu-separator" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="mx-context-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Components ─────────────────────────────────────────────────────────────────
|
// ── Components ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -317,8 +397,43 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
const [editText, setEditText] = useState(tweet.content);
|
const [editText, setEditText] = useState(tweet.content);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const tweetUrl = `${window.location.origin}/feed/${tweet.id}`;
|
||||||
|
|
||||||
|
const ctxItems: ContextMenuItem[] = canModify
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Edit",
|
||||||
|
onClick: () => {
|
||||||
|
setEditText(tweet.content);
|
||||||
|
setEditing(true);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Share",
|
||||||
|
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "View",
|
||||||
|
onClick: () => { window.location.href = tweetUrl; },
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
label: "Share",
|
||||||
|
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await destroyTweet({
|
const res = await destroyTweet({
|
||||||
@@ -377,6 +492,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
className="mx-tweet"
|
className="mx-tweet"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
>
|
>
|
||||||
<div className="mx-tweet-avatar">
|
<div className="mx-tweet-avatar">
|
||||||
<span>M</span>
|
<span>M</span>
|
||||||
@@ -490,6 +606,14 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
|||||||
|
|
||||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
items={ctxItems}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,11 +896,268 @@ function RefreshButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useFollowUser(targetUserId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const followMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await followUser({
|
||||||
|
input: { followingId: targetUserId },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Follow failed");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unfollowMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await unfollowUser({
|
||||||
|
input: { followingId: targetUserId },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Unfollow failed");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
follow: () => followMutation.mutate(),
|
||||||
|
unfollow: () => unfollowMutation.mutate(),
|
||||||
|
isPending: followMutation.isPending || unfollowMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FollowButton({ amIFollowing, isPending, onToggle }: { amIFollowing: boolean; isPending: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`mx-follow-btn${amIFollowing ? " mx-follow-btn--following" : ""}`}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||||
|
>
|
||||||
|
{isPending ? "…" : amIFollowing ? "Unfollow" : "Follow"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const { follow, unfollow, isPending } = useFollowUser(user.id);
|
||||||
|
|
||||||
|
const userUrl = `${window.location.origin}/users/${user.id}`;
|
||||||
|
const canFollow = !!currentUserId && currentUserId !== user.id;
|
||||||
|
const amIFollowing = user.amIFollowing ?? false;
|
||||||
|
|
||||||
|
const ctxItems: ContextMenuItem[] = [
|
||||||
|
{ type: "item", label: "Share", onClick: () => navigator.clipboard.writeText(userUrl) },
|
||||||
|
...(canFollow ? [
|
||||||
|
{ type: "separator" as const },
|
||||||
|
amIFollowing
|
||||||
|
? { type: "item" as const, label: "Unfollow", onClick: unfollow }
|
||||||
|
: { type: "item" as const, label: "Follow", onClick: follow },
|
||||||
|
] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="mx-tweet"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||||
|
>
|
||||||
|
<div className="mx-tweet-avatar">
|
||||||
|
<span>M</span>
|
||||||
|
</div>
|
||||||
|
<div className="mx-tweet-body">
|
||||||
|
<div className="mx-tweet-header">
|
||||||
|
<span className="mx-tweet-handle">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
|
||||||
|
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
|
||||||
|
<span>{user.followerCount ?? 0} followers</span>
|
||||||
|
<span style={{ marginLeft: "12px" }}>{user.followingCount ?? 0} following</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canFollow && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||||
|
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxItems} onClose={() => setCtxMenu(null)} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserList() {
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load users");
|
||||||
|
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return users as User[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
|
||||||
|
|
||||||
|
const users = data ?? [];
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">No users yet</p>
|
||||||
|
<p className="mx-empty-sub">Be the first to sign up.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-feed">
|
||||||
|
{users.map((u) => (
|
||||||
|
<UserCard key={u.id} user={u} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDetail({ userId }: { userId: string }) {
|
||||||
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
|
const { follow, unfollow, isPending } = useFollowUser(userId);
|
||||||
|
const { data: user, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["user", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readUser({
|
||||||
|
fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"],
|
||||||
|
filter: { id: { eq: userId } },
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load user");
|
||||||
|
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return (results[0] as User) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError || !user) return <ErrorBanner message="Could not load user" />;
|
||||||
|
|
||||||
|
const canFollow = !!currentUserId && currentUserId !== userId;
|
||||||
|
const amIFollowing = user.amIFollowing ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-detail">
|
||||||
|
<div className="mx-detail-header">
|
||||||
|
<a href="/users" className="mx-back-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mx-detail-body">
|
||||||
|
<div className="mx-detail-author">
|
||||||
|
<div className="mx-tweet-avatar">
|
||||||
|
<span>M</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<span className="mx-tweet-handle">{user.email}</span>
|
||||||
|
{canFollow && (
|
||||||
|
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}>
|
||||||
|
<span><strong>{user.followerCount ?? 0}</strong> followers</span>
|
||||||
|
<span><strong>{user.followingCount ?? 0}</strong> following</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const appEl = document.getElementById("app")!;
|
const appEl = document.getElementById("app")!;
|
||||||
const email = appEl.dataset.currentUserEmail ?? "";
|
const email = appEl.dataset.currentUserEmail ?? "";
|
||||||
const userId = appEl.dataset.currentUserId ?? "";
|
const userId = appEl.dataset.currentUserId ?? "";
|
||||||
const tweetId = appEl.dataset.tweetId || null;
|
const tweetId = appEl.dataset.tweetId || null;
|
||||||
|
const page = appEl.dataset.page ?? "feed";
|
||||||
|
const profileUserId = appEl.dataset.userId || null;
|
||||||
|
|
||||||
|
const onFeedPage = page === "feed" || page === "tweet";
|
||||||
|
const onUsersPage = page === "users" || page === "user-detail";
|
||||||
|
|
||||||
|
function renderMain() {
|
||||||
|
switch (page) {
|
||||||
|
case "tweet":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Tweet</h1>
|
||||||
|
</header>
|
||||||
|
<TweetDetail tweetId={tweetId!} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "users":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Users</h1>
|
||||||
|
</header>
|
||||||
|
<UserList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "user-detail":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Profile</h1>
|
||||||
|
</header>
|
||||||
|
<UserDetail userId={profileUserId!} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Feed</h1>
|
||||||
|
<RefreshButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-compose-wrapper">
|
||||||
|
{email ? (
|
||||||
|
<ComposeTweet />
|
||||||
|
) : (
|
||||||
|
<div className="mx-signin-cta">
|
||||||
|
<p>Sign in to start mixing.</p>
|
||||||
|
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-divider" />
|
||||||
|
|
||||||
|
<Feed />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ email, userId }}>
|
<AuthCtx.Provider value={{ email, userId }}>
|
||||||
@@ -788,12 +1169,18 @@ function App() {
|
|||||||
<span className="mx-logo-text">Mixer</span>
|
<span className="mx-logo-text">Mixer</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mx-nav">
|
<nav className="mx-nav">
|
||||||
<a className="mx-nav-item mx-nav-active" href="/feed">
|
<a className={`mx-nav-item${onFeedPage ? " mx-nav-active" : ""}`} href="/feed">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
</svg>
|
</svg>
|
||||||
Feed
|
Feed
|
||||||
</a>
|
</a>
|
||||||
|
<a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||||
|
</svg>
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mx-sidebar-footer">
|
<div className="mx-sidebar-footer">
|
||||||
{email ? (
|
{email ? (
|
||||||
@@ -812,36 +1199,7 @@ function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="mx-main">
|
<main className="mx-main">
|
||||||
{tweetId ? (
|
{renderMain()}
|
||||||
<>
|
|
||||||
<header className="mx-header">
|
|
||||||
<h1 className="mx-header-title">Tweet</h1>
|
|
||||||
</header>
|
|
||||||
<TweetDetail tweetId={tweetId} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<header className="mx-header">
|
|
||||||
<h1 className="mx-header-title">Feed</h1>
|
|
||||||
<RefreshButton />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="mx-compose-wrapper">
|
|
||||||
{email ? (
|
|
||||||
<ComposeTweet />
|
|
||||||
) : (
|
|
||||||
<div className="mx-signin-cta">
|
|
||||||
<p>Sign in to start mixing.</p>
|
|
||||||
<a className="mx-btn-post" href="/register">Sign in</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-divider" />
|
|
||||||
|
|
||||||
<Feed />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div className="mx-rightbar">
|
<div className="mx-rightbar">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ config :mixer, MixerWeb.Endpoint,
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Configure Swoosh API Client
|
# Configure Swoosh API Client
|
||||||
config :swoosh, api_client: Swoosh.ApiClient.Req
|
config :swoosh, api_client: Swoosh.ApiClient.Hackney
|
||||||
|
|
||||||
# Disable Swoosh Local Memory Storage
|
# Disable Swoosh Local Memory Storage
|
||||||
config :swoosh, local: false
|
config :swoosh, local: false
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ if config_env() == :prod do
|
|||||||
You can generate one by calling: mix phx.gen.secret
|
You can generate one by calling: mix phx.gen.secret
|
||||||
"""
|
"""
|
||||||
|
|
||||||
host = System.get_env("PHX_HOST") || "example.com"
|
host = System.get_env("PHX_HOST") || "mixer.jimweaver.com"
|
||||||
|
|
||||||
config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
@@ -97,6 +97,12 @@ if config_env() == :prod do
|
|||||||
System.get_env("S3_ASSET_HOST") ||
|
System.get_env("S3_ASSET_HOST") ||
|
||||||
raise("Missing environment variable `S3_ASSET_HOST`!")
|
raise("Missing environment variable `S3_ASSET_HOST`!")
|
||||||
|
|
||||||
|
config :mixer, Mixer.Mailer,
|
||||||
|
adapter: Swoosh.Adapters.Brevo,
|
||||||
|
api_key:
|
||||||
|
System.get_env("BREVO_API_KEY") ||
|
||||||
|
raise("Missing environment variable `BREVO_API_KEY`!")
|
||||||
|
|
||||||
# ## SSL Support
|
# ## SSL Support
|
||||||
#
|
#
|
||||||
# To get SSL working, you will need to add the `https` key
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
defmodule Mixer.Accounts do
|
defmodule Mixer.Accounts do
|
||||||
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain]
|
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
show? true
|
show? true
|
||||||
@@ -9,5 +9,18 @@ defmodule Mixer.Accounts do
|
|||||||
resource Mixer.Accounts.Token
|
resource Mixer.Accounts.Token
|
||||||
resource Mixer.Accounts.User
|
resource Mixer.Accounts.User
|
||||||
resource Mixer.Accounts.ApiKey
|
resource Mixer.Accounts.ApiKey
|
||||||
|
|
||||||
|
resource Mixer.Accounts.Follow
|
||||||
|
end
|
||||||
|
|
||||||
|
typescript_rpc do
|
||||||
|
resource Mixer.Accounts.User do
|
||||||
|
rpc_action :read_user, :read
|
||||||
|
end
|
||||||
|
resource Mixer.Accounts.Follow do
|
||||||
|
rpc_action :read_follow, :read
|
||||||
|
rpc_action :follow_user, :follow
|
||||||
|
rpc_action :unfollow_user, :unfollow
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
102
lib/mixer/accounts/follow.ex
Normal file
102
lib/mixer/accounts/follow.ex
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
defmodule Mixer.Accounts.Follow do
|
||||||
|
require Ash.Query
|
||||||
|
use Ash.Resource,
|
||||||
|
domain: Mixer.Accounts,
|
||||||
|
data_layer: AshPostgres.DataLayer,
|
||||||
|
authorizers: [Ash.Policy.Authorizer],
|
||||||
|
extensions: [AshTypescript.Resource]
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "follows"
|
||||||
|
repo Mixer.Repo
|
||||||
|
|
||||||
|
references do
|
||||||
|
reference :follower, on_delete: :delete
|
||||||
|
reference :following, on_delete: :delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
typescript do
|
||||||
|
type_name "follows"
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id
|
||||||
|
create_timestamp :created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :follower, Mixer.Accounts.User do
|
||||||
|
primary_key? true
|
||||||
|
allow_nil? false
|
||||||
|
attribute_writable? true
|
||||||
|
end
|
||||||
|
|
||||||
|
belongs_to :following, Mixer.Accounts.User do
|
||||||
|
primary_key? true
|
||||||
|
allow_nil? false
|
||||||
|
attribute_writable? true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read, :destroy]
|
||||||
|
|
||||||
|
create :follow do
|
||||||
|
primary? true
|
||||||
|
upsert? true
|
||||||
|
upsert_identity :unique_follow
|
||||||
|
accept [:following_id]
|
||||||
|
change relate_actor(:follower)
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
|
||||||
|
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
||||||
|
|
||||||
|
if follower_id == following_id do
|
||||||
|
{:error, field: :following_id, message: "You cannot follow yourself"}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
action :unfollow do
|
||||||
|
argument :following_id, :uuid, allow_nil?: false
|
||||||
|
|
||||||
|
run fn input, context ->
|
||||||
|
actor = context.actor
|
||||||
|
|
||||||
|
Mixer.Accounts.Follow
|
||||||
|
|> Ash.Query.filter(
|
||||||
|
Ash.Expr.expr(
|
||||||
|
follower_id == ^actor.id and following_id == ^input.arguments.following_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Ash.read_one(authorize?: false)
|
||||||
|
|> case do
|
||||||
|
{:ok, nil} -> :ok
|
||||||
|
{:ok, follow} -> Ash.destroy(follow, authorize?: false)
|
||||||
|
{:error, error} -> {:error, error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_follow, [:follower_id, :following_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
policy action_type(:read) do
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action(:follow) do
|
||||||
|
authorize_if actor_present()
|
||||||
|
end
|
||||||
|
|
||||||
|
policy action(:unfollow) do
|
||||||
|
authorize_if actor_present()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
defmodule Mixer.Accounts.User do
|
defmodule Mixer.Accounts.User do
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
otp_app: :mixer,
|
otp_app: :mixer,
|
||||||
domain: Mixer.Accounts,
|
domain: Mixer.Accounts,
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
authorizers: [Ash.Policy.Authorizer],
|
authorizers: [Ash.Policy.Authorizer],
|
||||||
extensions: [AshAuthentication]
|
extensions: [AshAuthentication, AshTypescript.Resource]
|
||||||
|
|
||||||
authentication do
|
authentication do
|
||||||
add_ons do
|
add_ons do
|
||||||
@@ -66,6 +68,10 @@ defmodule Mixer.Accounts.User do
|
|||||||
repo Mixer.Repo
|
repo Mixer.Repo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
typescript do
|
||||||
|
type_name "users"
|
||||||
|
end
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read]
|
defaults [:read]
|
||||||
|
|
||||||
@@ -282,6 +288,10 @@ defmodule Mixer.Accounts.User do
|
|||||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||||
authorize_if always()
|
authorize_if always()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
policy action_type(:read) do
|
||||||
|
authorize_if always()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
@@ -307,6 +317,34 @@ defmodule Mixer.Accounts.User do
|
|||||||
has_many :tweet_likes, Mixer.Posts.TweetLike
|
has_many :tweet_likes, Mixer.Posts.TweetLike
|
||||||
|
|
||||||
has_many :tweets, Mixer.Posts.Tweet
|
has_many :tweets, Mixer.Posts.Tweet
|
||||||
|
|
||||||
|
has_many :followers, Mixer.Accounts.Follow do
|
||||||
|
destination_attribute :following_id
|
||||||
|
end
|
||||||
|
|
||||||
|
has_many :following, Mixer.Accounts.Follow do
|
||||||
|
destination_attribute :follower_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
aggregates do
|
||||||
|
count :follower_count, :followers do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
count :following_count, :following do
|
||||||
|
public? true
|
||||||
|
end
|
||||||
|
|
||||||
|
exists :am_i_following, :followers do
|
||||||
|
public? true
|
||||||
|
filter expr(follower_id == ^actor(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
first :my_follow_id, :followers, :id do
|
||||||
|
public? true
|
||||||
|
filter expr(follower_id == ^actor(:id))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
identities do
|
identities do
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
|||||||
end
|
end
|
||||||
|
|
||||||
new()
|
new()
|
||||||
# TODO: Replace with your email
|
|> from({"noreply", "noreply@jimweaver.com"})
|
||||||
|> from({"noreply", "noreply@example.com"})
|
|
||||||
|> to(to_string(email))
|
|> to(to_string(email))
|
||||||
|> subject("Your login link")
|
|> subject("Your login link")
|
||||||
|> html_body(body(token: token, email: email))
|
|> html_body(body(token: token, email: email))
|
||||||
@@ -31,10 +30,79 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
|||||||
|
|
||||||
defp body(params) do
|
defp body(params) do
|
||||||
# NOTE: You may have to change this to match your magic link acceptance URL.
|
# NOTE: You may have to change this to match your magic link acceptance URL.
|
||||||
|
link = url(~p"/magic_link/#{params[:token]}")
|
||||||
|
email_template("Your magic link", "Hello, #{params[:email]}!", """
|
||||||
|
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
If you didn't request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
""", link, "Sign In to Mixer")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp email_template(title, greeting, content, button_url, button_label) do
|
||||||
"""
|
"""
|
||||||
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
|
<!DOCTYPE html>
|
||||||
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p>
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>#{title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||||
|
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||||
|
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Accent bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||||
|
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||||
|
#{content}
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||||
|
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||||
|
This is an automated message — replies to this address are not monitored.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
You received this because you have an account on Mixer.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
new()
|
new()
|
||||||
# TODO: Replace with your email
|
|> from({"noreply", "noreply@jimweaver.com"})
|
||||||
|> from({"noreply", "noreply@example.com"})
|
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject("Confirm your email address")
|
|> subject("Confirm your email address")
|
||||||
|> html_body(body(token: token))
|
|> html_body(body(token: token))
|
||||||
@@ -22,11 +21,79 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp body(params) do
|
defp body(params) do
|
||||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
link = url(~p"/confirm_new_user/#{params[:token]}")
|
||||||
|
email_template("Confirm your email", "Welcome to Mixer!", """
|
||||||
|
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
Thanks for signing up. Just one more step — confirm your email address to activate your account.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
If you didn't create an account on Mixer, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
""", link, "Confirm Email Address")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp email_template(title, greeting, content, button_url, button_label) do
|
||||||
"""
|
"""
|
||||||
<p>Click this link to confirm your email:</p>
|
<!DOCTYPE html>
|
||||||
<p><a href="#{url}">#{url}</a></p>
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>#{title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||||
|
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||||
|
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Accent bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||||
|
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||||
|
#{content}
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||||
|
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||||
|
This is an automated message — replies to this address are not monitored.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
You received this because you signed up for Mixer.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
|||||||
@impl true
|
@impl true
|
||||||
def send(user, token, _) do
|
def send(user, token, _) do
|
||||||
new()
|
new()
|
||||||
# TODO: Replace with your email
|
|> from({"noreply", "noreply@jimweaver.com"})
|
||||||
|> from({"noreply", "noreply@example.com"})
|
|
||||||
|> to(to_string(user.email))
|
|> to(to_string(user.email))
|
||||||
|> subject("Reset your password")
|
|> subject("Reset your password")
|
||||||
|> html_body(body(token: token))
|
|> html_body(body(token: token))
|
||||||
@@ -22,11 +21,79 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp body(params) do
|
defp body(params) do
|
||||||
url = url(~p"/password-reset/#{params[:token]}")
|
link = url(~p"/password-reset/#{params[:token]}")
|
||||||
|
email_template("Reset your password", "Password reset request", """
|
||||||
|
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||||
|
If you didn't request a password reset, you can safely ignore this email — your password will not change.
|
||||||
|
</p>
|
||||||
|
""", link, "Reset My Password")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp email_template(title, greeting, content, button_url, button_label) do
|
||||||
"""
|
"""
|
||||||
<p>Click this link to reset your password:</p>
|
<!DOCTYPE html>
|
||||||
<p><a href="#{url}">#{url}</a></p>
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>#{title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||||
|
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||||
|
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Accent bar -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||||
|
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||||
|
#{content}
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||||
|
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||||
|
This is an automated message — replies to this address are not monitored.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||||
|
You received this because a password reset was requested for your Mixer account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ defmodule MixerWeb.PageController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def index(conn, _params) do
|
def index(conn, _params) do
|
||||||
render_spa(conn, nil)
|
render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil})
|
||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, %{"tweet_id" => tweet_id}) do
|
def show(conn, %{"tweet_id" => tweet_id}) do
|
||||||
render_spa(conn, tweet_id)
|
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_spa(conn, tweet_id) do
|
def users_index(conn, _params) do
|
||||||
|
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_show(conn, %{"user_id" => user_id}) do
|
||||||
|
render_spa(conn, %{page: "user-detail", tweet_id: nil, user_id: user_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_spa(conn, %{page: page, tweet_id: tweet_id, user_id: user_id}) do
|
||||||
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
|
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
|
||||||
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
|
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
|
||||||
|
|
||||||
@@ -22,7 +30,9 @@ defmodule MixerWeb.PageController do
|
|||||||
|> render(:index,
|
|> render(:index,
|
||||||
current_user: conn.assigns[:current_user],
|
current_user: conn.assigns[:current_user],
|
||||||
media_host: "#{asset_host}/#{bucket}",
|
media_host: "#{asset_host}/#{bucket}",
|
||||||
tweet_id: tweet_id
|
page: page,
|
||||||
|
tweet_id: tweet_id,
|
||||||
|
user_id: user_id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,5 +2,7 @@
|
|||||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||||
data-asset-host={@media_host}
|
data-asset-host={@media_host}
|
||||||
data-tweet-id={@tweet_id || ""}>
|
data-page={@page}
|
||||||
|
data-tweet-id={@tweet_id || ""}
|
||||||
|
data-user-id={@user_id || ""}>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ defmodule MixerWeb.Router do
|
|||||||
get "/", PageController, :home
|
get "/", PageController, :home
|
||||||
get "/feed", PageController, :index
|
get "/feed", PageController, :index
|
||||||
get "/feed/:tweet_id", PageController, :show
|
get "/feed/:tweet_id", PageController, :show
|
||||||
|
get "/users", PageController, :users_index
|
||||||
|
get "/users/:user_id", PageController, :user_show
|
||||||
post "/rpc/run", AshTypescriptRpcController, :run
|
post "/rpc/run", AshTypescriptRpcController, :run
|
||||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||||
post "/upload", UploadController, :create
|
post "/upload", UploadController, :create
|
||||||
|
|||||||
53
priv/repo/migrations/20260403012654_follow_feature.exs
Normal file
53
priv/repo/migrations/20260403012654_follow_feature.exs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
defmodule Mixer.Repo.Migrations.FollowFeature do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:follows, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||||
|
|
||||||
|
add :created_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
|
||||||
|
add :follower_id,
|
||||||
|
references(:users,
|
||||||
|
column: :id,
|
||||||
|
name: "follows_follower_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :delete_all
|
||||||
|
), primary_key: true, null: false
|
||||||
|
|
||||||
|
add :following_id,
|
||||||
|
references(:users,
|
||||||
|
column: :id,
|
||||||
|
name: "follows_following_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :delete_all
|
||||||
|
), primary_key: true, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:follows, [:follower_id, :following_id],
|
||||||
|
name: "follows_unique_follow_index"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists unique_index(:follows, [:follower_id, :following_id],
|
||||||
|
name: "follows_unique_follow_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
drop constraint(:follows, "follows_follower_id_fkey")
|
||||||
|
|
||||||
|
drop constraint(:follows, "follows_following_id_fkey")
|
||||||
|
|
||||||
|
drop table(:follows)
|
||||||
|
end
|
||||||
|
end
|
||||||
125
priv/resource_snapshots/repo/follows/20260403012655.json
Normal file
125
priv/resource_snapshots/repo/follows/20260403012655.json
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "created_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "follows_follower_id_fkey",
|
||||||
|
"on_delete": "delete",
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "follower_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "follows_following_id_fkey",
|
||||||
|
"on_delete": "delete",
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "following_id",
|
||||||
|
"type": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "F54EC67C792D0DBDA389BB372D0403325AEAD4C232678C19BB951AA058813F0A",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "follows_unique_follow_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "follower_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "following_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_follow",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mixer.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "follows"
|
||||||
|
}
|
||||||
BIN
priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico
Normal file
BIN
priv/static/favicon-91f37b602a111216f1eef3aa337ad763.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
@@ -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 |
BIN
priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz
Normal file
BIN
priv/static/images/logo-06a11be1f2cdde2c851763d00bdd2e80.svg.gz
Normal file
Binary file not shown.
BIN
priv/static/images/logo.svg.gz
Normal file
BIN
priv/static/images/logo.svg.gz
Normal file
Binary file not shown.
5
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt
Normal file
5
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.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: /
|
||||||
BIN
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz
Normal file
BIN
priv/static/robots-9e2c81b0855bbff2baa8371bc4a78186.txt.gz
Normal file
Binary file not shown.
BIN
priv/static/robots.txt.gz
Normal file
BIN
priv/static/robots.txt.gz
Normal file
Binary file not shown.
2
rel/env.sh.eex
Normal file
2
rel/env.sh.eex
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
export RELEASE_DISTRIBUTION=none
|
||||||
Reference in New Issue
Block a user