mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
feat(auth): implement purple music app authentication
This commit is contained in:
@@ -25,5 +25,5 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,22 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"http:default"
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "http://www.deezer.com/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.deezer.com/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://*.deezer.com/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://*.deezer.com/**"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
172
src/lib/services/deezer.ts
Normal file
172
src/lib/services/deezer.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import type { DeezerUser } from '$lib/stores/deezer';
|
||||||
|
|
||||||
|
// Deezer API response types
|
||||||
|
interface DeezerUserData {
|
||||||
|
USER: {
|
||||||
|
USER_ID: number;
|
||||||
|
BLOG_NAME: string;
|
||||||
|
USER_PICTURE?: string;
|
||||||
|
MULTI_ACCOUNT?: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
IS_SUB_ACCOUNT: boolean;
|
||||||
|
};
|
||||||
|
OPTIONS: {
|
||||||
|
license_token: string;
|
||||||
|
web_hq?: boolean;
|
||||||
|
mobile_hq?: boolean;
|
||||||
|
web_lossless?: boolean;
|
||||||
|
mobile_lossless?: boolean;
|
||||||
|
license_country: string;
|
||||||
|
};
|
||||||
|
SETTING?: {
|
||||||
|
global?: {
|
||||||
|
language?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
LOVEDTRACKS_ID?: number;
|
||||||
|
};
|
||||||
|
checkForm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GWAPIResponse {
|
||||||
|
results: DeezerUserData;
|
||||||
|
error: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeezerAPI {
|
||||||
|
private httpHeaders: Record<string, string>;
|
||||||
|
private arl: string | null = null;
|
||||||
|
private apiToken: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.httpHeaders = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ARL cookie for authentication
|
||||||
|
setArl(arl: string): void {
|
||||||
|
this.arl = arl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API token from getUserData
|
||||||
|
private async getToken(): Promise<string> {
|
||||||
|
const userData = await this.getUserData();
|
||||||
|
return userData.checkForm || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Deezer GW API
|
||||||
|
private async apiCall(method: string, args: any = {}, params: any = {}): Promise<any> {
|
||||||
|
if (!this.apiToken && method !== 'deezer.getUserData') {
|
||||||
|
this.apiToken = await this.getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
api_version: '1.0',
|
||||||
|
api_token: method === 'deezer.getUserData' ? 'null' : (this.apiToken || 'null'),
|
||||||
|
input: '3',
|
||||||
|
method,
|
||||||
|
...params
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://www.deezer.com/ajax/gw-light.php?${searchParams.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...this.httpHeaders,
|
||||||
|
'Cookie': this.arl ? `arl=${this.arl}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(args)
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultJson: GWAPIResponse = await response.json();
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
if (resultJson.error && (Array.isArray(resultJson.error) ? resultJson.error.length : Object.keys(resultJson.error).length)) {
|
||||||
|
const errorStr = JSON.stringify(resultJson.error);
|
||||||
|
|
||||||
|
// Handle invalid token - retry with new token
|
||||||
|
if (errorStr.includes('invalid api token') || errorStr.includes('Invalid CSRF token')) {
|
||||||
|
this.apiToken = await this.getToken();
|
||||||
|
return this.apiCall(method, args, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Deezer API Error: ${errorStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set token from getUserData response
|
||||||
|
if (!this.apiToken && method === 'deezer.getUserData') {
|
||||||
|
this.apiToken = resultJson.results.checkForm || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultJson.results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] deezer.gw', method, args, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
async getUserData(): Promise<DeezerUserData> {
|
||||||
|
return this.apiCall('deezer.getUserData');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login via ARL token
|
||||||
|
async loginViaArl(arl: string): Promise<{ success: boolean; user?: DeezerUser; error?: string }> {
|
||||||
|
try {
|
||||||
|
this.setArl(arl);
|
||||||
|
|
||||||
|
const userData = await this.getUserData();
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
if (!userData || !userData.USER || userData.USER.USER_ID === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid ARL token or not logged in'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build user object
|
||||||
|
const user: DeezerUser = {
|
||||||
|
id: userData.USER.USER_ID,
|
||||||
|
name: userData.USER.BLOG_NAME,
|
||||||
|
picture: userData.USER.USER_PICTURE || '',
|
||||||
|
license_token: userData.USER.OPTIONS.license_token,
|
||||||
|
can_stream_hq: userData.USER.OPTIONS.web_hq || userData.USER.OPTIONS.mobile_hq || false,
|
||||||
|
can_stream_lossless: userData.USER.OPTIONS.web_lossless || userData.USER.OPTIONS.mobile_lossless || false,
|
||||||
|
country: userData.USER.OPTIONS.license_country,
|
||||||
|
language: userData.USER.SETTING?.global?.language || 'en'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if authentication is working
|
||||||
|
async testAuth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const userData = await this.getUserData();
|
||||||
|
return userData && userData.USER && userData.USER.USER_ID !== 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const deezerAPI = new DeezerAPI();
|
||||||
86
src/lib/stores/deezer.ts
Normal file
86
src/lib/stores/deezer.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Deezer User interface
|
||||||
|
export interface DeezerUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picture?: string;
|
||||||
|
license_token?: string;
|
||||||
|
can_stream_hq?: boolean;
|
||||||
|
can_stream_lossless?: boolean;
|
||||||
|
country?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer auth state
|
||||||
|
export interface DeezerAuthState {
|
||||||
|
arl: string | null;
|
||||||
|
user: DeezerUser | null;
|
||||||
|
loggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the store with deezer.json
|
||||||
|
const store = new LazyStore('deezer.json');
|
||||||
|
|
||||||
|
// Default state
|
||||||
|
const defaultState: DeezerAuthState = {
|
||||||
|
arl: null,
|
||||||
|
user: null,
|
||||||
|
loggedIn: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a writable store for reactive UI updates
|
||||||
|
export const deezerAuth: Writable<DeezerAuthState> = writable(defaultState);
|
||||||
|
|
||||||
|
// Load Deezer auth state from store
|
||||||
|
export async function loadDeezerAuth(): Promise<void> {
|
||||||
|
const arl = await store.get<string>('arl');
|
||||||
|
const user = await store.get<DeezerUser>('user');
|
||||||
|
|
||||||
|
deezerAuth.set({
|
||||||
|
arl: arl ?? null,
|
||||||
|
user: user ?? null,
|
||||||
|
loggedIn: !!(arl && user)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save ARL token
|
||||||
|
export async function saveArl(arl: string): Promise<void> {
|
||||||
|
await store.set('arl', arl);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deezerAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
arl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user data
|
||||||
|
export async function saveUser(user: DeezerUser): Promise<void> {
|
||||||
|
await store.set('user', user);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deezerAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
user,
|
||||||
|
loggedIn: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear auth (logout)
|
||||||
|
export async function clearDeezerAuth(): Promise<void> {
|
||||||
|
await store.delete('arl');
|
||||||
|
await store.delete('user');
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deezerAuth.set(defaultState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ARL token
|
||||||
|
export async function getArl(): Promise<string | null> {
|
||||||
|
return (await store.get<string>('arl')) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on module load
|
||||||
|
loadDeezerAuth();
|
||||||
303
src/routes/services/deezer/+page.svelte
Normal file
303
src/routes/services/deezer/+page.svelte
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
|
||||||
|
let arlInput = $state('');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
let testingAuth = $state(false);
|
||||||
|
let authTestResult = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadDeezerAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!arlInput || arlInput.trim().length === 0) {
|
||||||
|
errorMessage = 'Please enter an ARL token';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arlInput.trim().length !== 192) {
|
||||||
|
errorMessage = 'ARL token should be 192 characters long';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deezerAPI.loginViaArl(arlInput.trim());
|
||||||
|
|
||||||
|
if (result.success && result.user) {
|
||||||
|
await saveArl(arlInput.trim());
|
||||||
|
await saveUser(result.user);
|
||||||
|
successMessage = `Successfully logged in as ${result.user.name}!`;
|
||||||
|
arlInput = '';
|
||||||
|
} else {
|
||||||
|
errorMessage = result.error || 'Login failed. Please check your ARL token.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await clearDeezerAuth();
|
||||||
|
successMessage = 'Logged out successfully';
|
||||||
|
errorMessage = '';
|
||||||
|
authTestResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAuthentication() {
|
||||||
|
if (!$deezerAuth.arl) {
|
||||||
|
authTestResult = 'Not logged in';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testingAuth = true;
|
||||||
|
authTestResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
deezerAPI.setArl($deezerAuth.arl);
|
||||||
|
const isValid = await deezerAPI.testAuth();
|
||||||
|
authTestResult = isValid ? '✓ Authentication is working!' : '✗ Authentication failed';
|
||||||
|
} catch (error) {
|
||||||
|
authTestResult = `✗ Test failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
} finally {
|
||||||
|
testingAuth = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="deezer-page">
|
||||||
|
<h2>Deezer Authentication</h2>
|
||||||
|
|
||||||
|
{#if !$deezerAuth.loggedIn}
|
||||||
|
<!-- Login Form -->
|
||||||
|
<section class="window login-section">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Login to Deezer</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<p>Enter your Deezer ARL token to authenticate:</p>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="arl-input">ARL Token</label>
|
||||||
|
<input
|
||||||
|
id="arl-input"
|
||||||
|
type="password"
|
||||||
|
bind:value={arlInput}
|
||||||
|
placeholder="192 character ARL token"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="error-message">
|
||||||
|
⚠ {errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="success-message">
|
||||||
|
✓ {successMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleLogin} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<details class="instructions">
|
||||||
|
<summary>How to get your ARL token</summary>
|
||||||
|
<div class="instructions-content">
|
||||||
|
<ol>
|
||||||
|
<li>Open Browser</li>
|
||||||
|
<li>Go to <strong>www.deezer.com</strong> and log into your account</li>
|
||||||
|
<li>After logging in press <strong>F12</strong> to open up Developer Tools</li>
|
||||||
|
<li>Go under the <strong>Application</strong> tab (if you don't see it click the double arrow)</li>
|
||||||
|
<li>Open the <strong>cookie</strong> dropdown</li>
|
||||||
|
<li>Select <strong>www.deezer.com</strong></li>
|
||||||
|
<li>Find the <strong>arl</strong> cookie (It should be 192 chars long)</li>
|
||||||
|
<li>Make sure only copy the value and not the entire cookie</li>
|
||||||
|
<li>That's your ARL, now you can use it in the app</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<!-- Logged In View -->
|
||||||
|
<section class="window user-section">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">User Info</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Name:</label>
|
||||||
|
<span>{$deezerAuth.user?.name || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>User ID:</label>
|
||||||
|
<span>{$deezerAuth.user?.id || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Country:</label>
|
||||||
|
<span>{$deezerAuth.user?.country || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>HQ Streaming:</label>
|
||||||
|
<span>{$deezerAuth.user?.can_stream_hq ? '✓ Yes' : '✗ No'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Lossless Streaming:</label>
|
||||||
|
<span>{$deezerAuth.user?.can_stream_lossless ? '✓ Yes' : '✗ No'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="success-message">
|
||||||
|
✓ {successMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Test Authentication -->
|
||||||
|
<section class="window test-section">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Test Authentication</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<p>Test if your authentication is working:</p>
|
||||||
|
|
||||||
|
{#if authTestResult}
|
||||||
|
<div class={authTestResult.startsWith('✓') ? 'success-message' : 'error-message'}>
|
||||||
|
{authTestResult}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={testAuthentication} disabled={testingAuth}>
|
||||||
|
{testingAuth ? 'Testing...' : 'Test Authentication'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.deezer-page {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section,
|
||||||
|
.user-section,
|
||||||
|
.test-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row-stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffcccc;
|
||||||
|
color: #cc0000;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
background-color: #ccffcc;
|
||||||
|
color: #008800;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #008800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--button-shadow, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user